Pattern

Wizard

Creating something in steps: the modal shell with a step bar, a fixed-height step area, Cancel on the first step and Back after, on the left, and the primary on the right.

Copy it from /dashboard/accounts/:acctId/sites/:siteId/submitted-forms/:kind

Steps with validation and a discard confirmation

Cancel on the first step and Back after, on the left; "Next" until the last step, which shows its verb. Next submits the form, so pressing it before an application is picked shows the sentence under the picker. Close after picking an application or another source to see the discard confirmation.

Route
/dashboard/accounts/:acctId/sites/:siteId/submitted-forms/:kind
Screenshot
docs/agents/ui-patterns/wizard-1440.png
import { useHeldRequest } from '~/components/ui/use-held-request.ts'

const defaultValues: Record<string, string> = {
  source: 'previous',
  sourceApplicationId: '',
}

// Inside the Form, where the form's values can be read.
function EntriesCheck({ register }: { register: (check: () => boolean) => void }) {
  const form = useFormContext<Record<string, unknown>>()
  useEffect(() => {
    register(() =>
      Object.entries(form.transient.value()).some(
        ([name, value]) => (value ?? '') !== (defaultValues[name] ?? ''),
      ),
    )
  })
  return null
}

// In the route:
const [isConfirmingDiscard, setIsConfirmingDiscard] = useState(false)
const hasEntries = useRef<() => boolean>(() => false)
const registerEntriesCheck = useCallback((check: () => boolean) => {
  hasEntries.current = check
}, [])
const requestClose = () => {
  if (hasEntries.current()) setIsConfirmingDiscard(true)
  else onClose()
}
const keepEditing = () => setIsConfirmingDiscard(false)
const discardAndClose = () => {
  setIsConfirmingDiscard(false)
  onClose()
}
const STEP_IDS = ['source', 'applicant', 'documents', 'details', 'review']
const [step, setStep] = useState(STEP_IDS[0])
const index = STEP_IDS.indexOf(step)
const formRef = useRef<HTMLFormElement>(null)
const saveHold = useHeldRequest(fetcher.data)
const goBack = () => setStep(STEP_IDS[index - 1])
const handleSubmit = () => {
  if (index < STEP_IDS.length - 1) setStep(STEP_IDS[index + 1])
  else if (formRef.current) {
    saveHold.hold(
      fetcher.submit(new FormData(formRef.current), { method: 'post' }),
    )
  }
}

<WizardShell
  isOpen={isOpen}
  onRequestClose={requestClose}
  size="xl"
  isBusy={saveHold.isHeld}
  header={{
    icon: 'form',
    title: 'New Adoption Application',
    description: 'For an applicant who doesn\'t need to fill out the online form.',
  }}
  step={step}
  steps={[
    {
      id: 'source',
      label: 'Source',
      content: <>{/* the source step */}</>,
    },
    {
      id: 'applicant',
      label: 'Applicant',
      content: <>{/* the applicant step */}</>,
    },
    {
      id: 'documents',
      label: 'Documents',
      content: <>{/* the documents step */}</>,
    },
    {
      id: 'details',
      label: 'Details',
      content: <>{/* the details step */}</>,
    },
    {
      id: 'review',
      label: 'Review',
      content: <>{/* the review step */}</>,
    },
  ]}
  form="new-application"
  renderForm={stepArea => (
    <Form
      id="new-application"
      validator={validator}
      fetcher={fetcher}
      formRef={formRef}
      defaultValues={defaultValues}
      onSubmit={handleSubmit}
    >
      <EntriesCheck register={registerEntriesCheck} />
      {stepArea}
    </Form>
  )}
  footer={{
    onBack: goBack,
    primary: {
      label: 'Create Application',
      pendingLabel: 'Creating...',
    },
  }}
/>
<ConfirmModal
  isOpen={isConfirmingDiscard}
  icon="danger"
  title="Discard this application?"
  description="What you've entered will be lost and no application will be created."
  cancelLabel="Keep editing"
  confirm={{
    label: 'Discard',
    tone: 'danger',
    onPress: discardAndClose,
  }}
  onCancel={keepEditing}
/>

Loading, with no steps

While the wizard loads what it needs, the header and one line, with no step bar and no footer. A refusal, such as an animal that is not on the Active tab, takes the same shape.

Route
/dashboard/accounts/:acctId/sites/:siteId/animals/:group/:id/edit
<WizardShell
  isOpen={isOpen}
  onRequestClose={onClose3}
  size="xl"
  header={{
    icon: 'animal',
    title: 'Transfer Out Animal',
  }}
>
  <p
    className="py-5 text-center text-sm text-gray-500"
  >
    Loading...
  </p>
</WizardShell>

Inline, as a card on a page

The same steps and footer without an overlay or a header, for a wizard that is the page itself. The step area grows instead of scrolling, so the page scrolls. Cancel leaves the page, here shown as a message.

Route
/dashboard/accounts/:acctId/sites/:siteId/volunteers/pending/:responseId

Step 1 of 3 ยท Note

import { useHeldRequest } from '~/components/ui/use-held-request.ts'

const STEP_IDS = ['note', 'roles', 'review']
const [step, setStep] = useState(STEP_IDS[0])
const index = STEP_IDS.indexOf(step)
const formRef = useRef<HTMLFormElement>(null)
const saveHold = useHeldRequest(fetcher.data)
const goBack = () => setStep(STEP_IDS[index - 1])
const handleSubmit = () => {
  if (index < STEP_IDS.length - 1) setStep(STEP_IDS[index + 1])
  else if (formRef.current) {
    saveHold.hold(
      fetcher.submit(new FormData(formRef.current), { method: 'post' }),
    )
  }
}

<WizardShell
  onRequestClose={onClose3}
  inline
  isBusy={saveHold.isHeld}
  step={step}
  steps={[
    {
      id: 'note',
      label: 'Note',
      content: <>{/* the note step */}</>,
    },
    {
      id: 'roles',
      label: 'Roles',
      content: <>{/* the roles step */}</>,
    },
    {
      id: 'review',
      label: 'Review',
      content: <>{/* the review step */}</>,
    },
  ]}
  form="approve-volunteer"
  renderForm={stepArea => (
    <Form
      id="approve-volunteer"
      validator={validator}
      fetcher={fetcher}
      formRef={formRef}
      onSubmit={handleSubmit}
    >
      {stepArea}
    </Form>
  )}
  footer={{
    onBack: goBack,
    primary: {
      label: 'Approve & add person',
      pendingLabel: 'Approving...',
    },
  }}
/>

One step, as the modal shell

A wizard with a single step is the modal shell: Cancel on the left, the verb on the right, no step bar. Like Euthanized, it is busy while it saves, and a click on the backdrop does not close it, so a stray click keeps typed input. Escape still closes it.

Route
/dashboard/accounts/:acctId/sites/:siteId/animals/:group/:id/edit
import { useHeldRequest } from '~/components/ui/use-held-request.ts'

const formRef = useRef<HTMLFormElement>(null)
const saveHold = useHeldRequest(fetcher.data)
// RVF calls this only with valid values.
const handleSubmit = () => {
  if (formRef.current) {
    saveHold.hold(
      fetcher.submit(new FormData(formRef.current), { method: 'post' }),
    )
  }
}

<ModalShell
  isOpen={isOpen}
  onRequestClose={onClose}
  size="lg"
  placement="top"
  isBusy={saveHold.isHeld}
  isDismissable={false}
  header={{
    icon: 'animal',
    title: 'Euthanized',
    description: 'Record why Pepper was euthanized. Pepper will move to the Completed tab and come off your website and exports.',
  }}
  footer={{
    actions: [
      {
        label: 'Cancel',
        onPress: onClose,
      },
    ],
    primary: {
      label: 'Save',
      pendingLabel: 'Saving...',
      submit: {
        form: 'euthanize-animal-form',
      },
    },
  }}
>
  <Form
    id="euthanize-animal-form"
    formRef={formRef}
    validator={validator}
    fetcher={fetcher}
    onSubmit={handleSubmit}
  >
    {/* the reason and the date */}
  </Form>
</ModalShell>