ModalForm / DrawerForm
form-dialogRuns validated forms inside modal or drawer containers with submit lifecycle handling.
Usage
Pop-up form
trigger triggers opening, and submission is successful (onFinish resolve) automatically closes.
<ModalForm
title="New employee"
trigger={<Button>New</Button>}
onFinish={async (values) => {
await api.create(values);
}}
>
<Field label="Name">
<Input placeholder="Required" />
</Field>
</ModalForm>Guarded close
Pressing the backdrop does not close it. An edited form asks for confirmation on Esc or the close button, while an untouched one closes straight away. Pass dismissible or confirmOnClose to turn either off.
<ModalForm title="Add school" form={form} onFinish={save}>
{/* The backdrop does not close it; once edited, Esc asks whether to discard */}
</ModalForm>
// Restore the primitive behaviour
<ModalForm dismissible confirmOnClose={false} … />Drawer form
DrawerForm reuses the same arrangement and slides out from the right edge, suitable for editing scenarios with many fields.
<DrawerForm
title="Edit Staff"
trigger={<Button variant="outline">Edit</Button>}
onFinish={(values) => api.update(values)}
>
<Field label="Name">
<Input />
</Field>
<Field label="Email">
<Input />
</Field>
</DrawerForm>Drawer welt direction
DrawerForm controls the welt direction through side (left / right).
<DrawerForm title="Filter" side="left" trigger={<Button variant="outline">Left drawer</Button>}>
<Field label="Keywords">
<Input />
</Field>
</DrawerForm>Custom button copy
submitText / cancelText overrides the default submit/cancel copy.
<ModalForm
title="Export report"
submitText="Export now"
cancelText="Think again"
trigger={<Button>Export</Button>}
>
<Field label="file name">
<Input placeholder="report.xlsx" />
</Field>
</ModalForm>When to use
Use ModalForm or DrawerForm for Add/Edit flows launched from a list page. ModalForm opens a centered dialog; DrawerForm opens from an edge and adds the side prop. Both compose Dialog or Drawer with validation and a submit footer. Use ProForm for an inline page form, Form for a bare container, or StepsForm for a wizard.
Import
import { ModalForm, DrawerForm } from "@hulianui/ui"Props
Public (ModalForm = FormDialogBaseProps; DrawerForm plus side on this basis):
| Name | Type | Default | Description |
|---|---|---|---|
| title * | string | - | Title (a11y label) |
| open | boolean | - | controlled switch |
| defaultOpen | boolean | - | Uncontrolled initial switch |
| form | FormInstance | - | useForm instance: If provided, it will automatically validate() before submission, but the verification will remain open. |
| submitText | string | locale.modalForm.submit | Submit button copy |
| cancelText | string | locale.modalForm.cancel | Cancel button copy |
| className | string | - | Container class name (control width, etc.) |
| side | DrawerSide | "right" | DrawerForm only: drawer welt direction |
| draggable | boolean | false | ModalForm only: lets the user move the dialog by holding the title (passed through to DialogContent.draggable) |
| dismissible | boolean | false | Whether pressing the backdrop closes the dialog. The opposite of the `Dialog` and `Drawer` primitives, because this component knows it holds a form and losing a half-filled one to a stray click costs far more than the convenience is worth (#343). Pass true to restore the primitive behaviour |
| confirmOnClose | boolean | true | Ask for confirmation before closing an edited form. The test is form.isDirty() or hasExternalChanges(), so it does nothing when neither is passed; an untouched form closes straight away, and so does the close that follows a successful submit. An edit form filled in asynchronously must pin its baseline with setFieldsValue(v, { markPristine: true }), or it asks to discard even when nothing was touched (see Form) |
| hasExternalChanges | () => boolean | - | An extra dirty test for state the form cannot see, OR-ed with `form.isDirty()` rather than replacing it (#351): either side being true asks first. Meant for compound controls inside the dialog that hold their own state instead of going through form.register — cascading region pickers, permission checkbox groups, tag editors. It is evaluated only at the moment the dialog is about to close, and works on its own when no form is passed |
| discardTitle | ReactNode | locale modalForm.discardTitle | Title of the discard confirmation |
| discardDescription | ReactNode | locale modalForm.discardDescription | Body copy of the discard confirmation |
Events
| Event | Type | Description |
|---|---|---|
| onOpenChange | (open: boolean) => void | Switch change callback |
| onFinish | (values: FormValues) => void | boolean | Promise<void | boolean> | Submit callback; return Promise → button loading; resolve (not false) automatically close; reject or return false to keep it open |
Slots
| Slot | Type | Description |
|---|---|---|
| trigger | ReactElement | Trigger element (for uncontrolled opening); can be omitted when controlled |
| children | ReactNode | form fields |
Usage guidelines
- Closing is determined by
onFinish: resolving to anything exceptfalsecloses automatically; reject or returnfalseto keep the form open. Do not also callonOpenChange(false)fromonFinish. - Only when
formis passed will it be automaticallyvalidate()before submission and will remain open if the verification fails; if the form is not passed, the verification will not be performed and the values will be handed over to onFinish directly. - Call
form.resetFields()yourself after a successful submission if fields should clear; the component does not reset them automatically.
Closing this form is not free (#343)
ModalForm and DrawerForm deliberately differ from the bare Dialog and Drawer: pressing the backdrop does not close them. A primitive is a general container where dismissing by clicking outside is reasonable. This component always holds a form, and wiping out eight filled fields because the pointer landed just outside the window costs far more than that convenience is worth.
The exits remain, with one confirmation added: Esc and the top-right close button first ask "Discard unsaved changes?" whenever form.isDirty() is true. Three cases never interrupt you: no form was passed, so dirtiness cannot be judged (unless hasExternalChanges below supplies the test); the form is untouched; and the close that follows a successful submit.
// Default: the backdrop does not close it, and an edited form asks first
<ModalForm title="Add school" form={form} onFinish={save}>…</ModalForm>
// Restore the primitive behaviour
<ModalForm dismissible confirmOnClose={false} …>…</ModalForm>
// Decide for yourself: details.reason separates backdrop, Esc and close button
<ModalForm onOpenChange={(open, details) => {
if (!open && details?.reason === "outside-press") details.cancel();
}} …>…</ModalForm>The confirmation is rendered by the component itself through AlertDialog, so it does not require a `ModalProvider`. The imperative modal.confirm shows nothing in an app that never mounted the provider, and since the close has already been intercepted at that point, the result would be a dialog that neither closes nor explains itself.
Fields the form cannot see also count as edits (#351)
The guard above tests form.isDirty() alone, which covers only fields routed through form.register. A fair share of the controls in a real back-office form hold their own state — a three-level region cascader, a permission checkbox group, a tag editor, linked subject and grade selects. They are not "one input, one value", so they never reach form.values. Pick a region, tick six permissions, press Esc, and the component reads the form as untouched, closes without asking, and the work is gone.
hasExternalChanges wires that side back in, OR-ed with `form.isDirty()`:
const [region, setRegion] = useState<string[]>([]);
const [codes, setCodes] = useState<string[]>([]);
const snapshot = useRef(""); // the baseline at open or backfill time, pinned by you
<ModalForm
title="Edit school"
form={form}
hasExternalChanges={() => snapshot.current !== JSON.stringify({ region, codes })}
onFinish={save}
>…</ModalForm>It takes a callback rather than a boolean so that no render has to run a snapshot comparison nobody reads; the answer matters only at the moment the dialog is about to close. Pass () => flag when a boolean is all you have.
Where `markPristine` stops: this side of the test lives entirely in your code. form.markPristine() and setFieldsValue(v, { markPristine: true }) pin the baseline of the form only — they never touch your useState, and the component keeps no snapshot of its own to reset. So an edit form filled in asynchronously must refresh its own snapshot in the same place it calls markPristine (reassigning snapshot.current in the example above), or the backfill counts as an edit on this side and asks to discard when nothing was touched.