Skip to content

FormDialog and FormSheet

A TanStack form in a dialog (a create flow) or a side sheet (an editor), finished: give it the form, a title and the fields. Enter and Ctrl+Enter submit, the save shows and blocks dismissal, it closes once onSubmit resolves and stays open with the reason when it throws, and closing with changes asks first.

Language
Appearance

Create flow in a dialog

The shortest real call: a form, a title, the fields. Submit it empty to see the field errors, with a name ending in “Test” to see a refusal, or with a name to save and close.

import { useState } from 'react';
import type { ReactNode } from 'react';
import type { Key } from 'react-aria-components';
import { Button, SelectItem } from '@arbol-ai/ui';
import { FormDialog, useAppForm } from '@arbol-ai/ui/form';

const roundTrip = async () => {
  // oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
  await new Promise((resolve) => {
    setTimeout(resolve, 900);
  });
};

const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Button
        onPress={() => {
          setIsOpen(true);
        }}
        variant="outline"
      >
        {label}
      </Button>
      {isOpen
        ? children(() => {
            setIsOpen(false);
          })
        : null}
    </>
  );
};

const NewContactExample = () => (
  <Opener label="New contact">{(close) => <NewContactDialog onClose={close} />}</Opener>
);

const NewContactDialog = ({ onClose }: { onClose: () => void }) => {
  const form = useAppForm({
    defaultValues: { name: '', phone: '', channel: null as Key | null },
    onSubmit: async () => {
      await roundTrip();
    },
    validators: {
      onSubmit: ({ value }) =>
        value.name.endsWith('Test') ? 'Test contacts are turned off for this clinic.' : undefined,
    },
  });
  return (
    <FormDialog form={form} onClose={onClose} submitLabel="Add contact" title="New contact">
      <form.AppField
        name="name"
        validators={{ onSubmit: ({ value }) => (value.trim() === '' ? 'Enter a name.' : undefined) }}
      >
        {(field) => <field.TextField isRequired label="Name" />}
      </form.AppField>
      <form.AppField
        name="phone"
        validators={{
          onSubmit: ({ value }) =>
            /^\+?[\d\s-]{7,}$/u.test(value) ? undefined : 'Enter a phone like +57 300 555 0101.',
        }}
      >
        {(field) => <field.TextField isRequired label="Phone" type="tel" />}
      </form.AppField>
      <form.AppField name="channel">
        {(field) => (
          <field.Select label="Preferred channel">
            <SelectItem id="whatsapp">WhatsApp</SelectItem>
            <SelectItem id="phone">Phone call</SelectItem>
          </field.Select>
        )}
      </form.AppField>
    </FormDialog>
  );
};

<NewContactExample />

Editor in a sheet

Change the note, then press Esc to see the discard question. Delete, at the start of the footer, asks for the name first.

import { TrashIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button, DeleteDialog, OverlayTrigger } from '@arbol-ai/ui';
import { FormSheet, useAppForm } from '@arbol-ai/ui/form';

const roundTrip = async () => {
  // oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
  await new Promise((resolve) => {
    setTimeout(resolve, 900);
  });
};

const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Button
        onPress={() => {
          setIsOpen(true);
        }}
        variant="outline"
      >
        {label}
      </Button>
      {isOpen
        ? children(() => {
            setIsOpen(false);
          })
        : null}
    </>
  );
};

const ContactEditorExample = () => (
  <Opener label="Edit Ana Torres">{(close) => <ContactEditor onClose={close} />}</Opener>
);

const ContactEditor = ({ onClose }: { onClose: () => void }) => {
  const form = useAppForm({
    defaultValues: { note: 'Prefers mornings.', receivesCampaigns: true },
    onSubmit: async () => {
      await roundTrip();
    },
  });
  return (
    <FormSheet
      description="Changes apply to every campaign."
      footerStart={
        <OverlayTrigger>
          <Button aria-label="Delete contact" size="icon" variant="ghost">
            <TrashIcon aria-hidden />
          </Button>
          <DeleteDialog
            body="Their conversations stay; they stop receiving campaigns."
            name="Ana Torres"
            onDelete={(close) => {
              close();
              onClose();
            }}
          />
        </OverlayTrigger>
      }
      form={form}
      onClose={onClose}
      size="md"
      submitLabel="Save changes"
      title="Ana Torres"
    >
      <form.AppField name="note">
        {(field) => <field.TextField description="Only your team sees it." label="Note" multiline />}
      </form.AppField>
      <form.AppField name="receivesCampaigns">
        {(field) => <field.Switch>Receives campaigns</field.Switch>}
      </form.AppField>
    </FormSheet>
  );
};

<ContactEditorExample />

Two steps

closeOnSubmit={false} on the first step and onBack on the second: Continue moves on, Back returns, and the last step closes once saved.

import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@arbol-ai/ui';
import { FormDialog, useAppForm } from '@arbol-ai/ui/form';

const roundTrip = async () => {
  // oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
  await new Promise((resolve) => {
    setTimeout(resolve, 900);
  });
};

const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Button
        onPress={() => {
          setIsOpen(true);
        }}
        variant="outline"
      >
        {label}
      </Button>
      {isOpen
        ? children(() => {
            setIsOpen(false);
          })
        : null}
    </>
  );
};

const MergeExample = () => (
  <Opener label="Merge contacts">{(close) => <MergeDialog onClose={close} />}</Opener>
);

const MergeDialog = ({ onClose }: { onClose: () => void }) => {
  const [step, setStep] = useState<'pick' | 'confirm'>('pick');
  const form = useAppForm({
    defaultValues: { duplicate: '' },
    onSubmit: async () => {
      if (step === 'pick') {
        setStep('confirm');
        return;
      }
      await roundTrip();
    },
  });
  return (
    <FormDialog
      closeOnSubmit={step === 'confirm'}
      description={
        step === 'pick'
          ? 'Who is the same person as Ana Torres?'
          : 'Their conversations, phones and facts move to Ana Torres.'
      }
      form={form}
      onBack={
        step === 'confirm'
          ? () => {
              setStep('pick');
            }
          : undefined
      }
      onClose={onClose}
      submitLabel={step === 'pick' ? 'Continue' : 'Merge contacts'}
      title="Merge contacts"
    >
      {step === 'pick' ? (
        <form.AppField
          name="duplicate"
          validators={{ onSubmit: ({ value }) => (value.trim() === '' ? 'Enter who to merge.' : undefined) }}
        >
          {(field) => <field.TextField description="Their name or phone." label="Duplicate contact" />}
        </form.AppField>
      ) : (
        <p className="text-sm">The duplicate is removed once everything moved.</p>
      )}
    </FormDialog>
  );
};

<MergeExample />

Source

The examples above are src/form/form-dialog.examples.tsx, which the library’s tests render too.

Type to search…

↑↓ navigate↵ selectEsc close