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 /
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>