FormModal
ImplementedForm-in-a-dialog pattern — thin composition over the Modal molecule that owns the <form> wrapper and the cancel/submit footer pair. The submit button binds to the form via the HTML form attribute, so Enter-in-field and native validation keep working while actions render in the footer (outside the form DOM the Modal portals past). Body-only usage covers content dialogs sharing the same chrome (W2).
components/organisms/FormModal/FormModal.tsxForm-in-a-dialog pattern — a thin composition over the Modal molecule that owns the <form> wrapper and the cancel/submit footer pair. The submit button binds to the form via the HTML form attribute, so Enter-in-field and native validation keep working while actions render in the footer (outside the form DOM the Modal portals past). Body-only usage covers content dialogs sharing the same chrome (W2).
FormModal
Organism — Account & Admin family
Controlled dialog for create/edit forms. When onSubmit is provided, children render inside a <form id> and the footer's submit button associates through the HTML form attribute — click and Enter both submit, native validation runs — without nesting the actions inside the form DOM. Without action labels it degrades to a body-only content dialog. Extracted from the dashboard form/content modal sites (PromoCodeManager, RevenueSummaryCard, ApprovalsSection) that previously sat on raw Modal.
This component supports light and dark mode.
Usage
FormModal is a controlled component — pair open with local state and always wire onClose. Pass onSubmit plus cancelLabel / submitLabel for the create/edit shape; the organism wraps children in a real <form> and renders the action pair in the modal footer. All strings are caller-owned (i18n).
import { FormModal, Input } from '@epasslive/design-system'
const [showForm, setShowForm] = useState(false)
const [saving, setSaving] = useState(false)
<FormModal
open={showForm}
onClose={() => setShowForm(false)}
title={t('formTitle')}
onSubmit={create}
cancelLabel={t('cancel')}
submitLabel={t('save')}
submitDisabled={saving}
>
<Input label={t('codeLabel')} required />
</FormModal>The form-attribute trick
The Modal portals its footer to document.body, past the form DOM — so the submit button cannot simply nest inside the <form>. FormModal instead gives the generated form a stable id and sets the HTML form attribute on the footer's submit button: clicking it, or pressing Enter in any field, submits the form and native validation runs — the same pattern TicketTransferModal validated (L8a composition fix). Provide a fully custom footer only when you need something the built-in pair cannot express; it overrides the generated actions.
Body-only dialogs
Children without onSubmit and without action labels render as a body-only content dialog — lists, tables, sample panels — sharing the same Modal chrome (portal, scrim, focus trap, Escape, scroll lock). Do not hand-roll a <form> inside children together with onSubmit — the organism already wraps children in a generated <form> (that would double-nest); without onSubmit a footer submit button binds no form and will not submit. For confirm/cancel-only dialogs use ConfirmModal, which owns the destructive action pair.
Accessibility
Dialog chrome comes from the Modal molecule — role="dialog", labelled by the title when set, ESC to close (topmost stacked modal only), focus trap with restore, body scroll lock. Pass title or ariaLabel so the dialog is named (WCAG 4.1.2). The action cluster exposes role="group" with actionsAriaLabel.
Props
open*booleanWhether the dialog is visible.
onClose*() => voidCalled when the dialog should close (ESC, scrim click).
titlestringHeading rendered in the dialog header.
ariaLabelstringAccessible name when no visible heading is used.
size"sm" | "md" | "lg" | "full"'md'Panel width — md, the form-dialog convention.
closeOnScrimClickbooleantrueSet false for flows that must be actioned explicitly.
idstringPassed through to the Modal root as the id base — the dialog title id becomes `<id>-title` (aria-labelledby).
onSubmit(event: React.FormEvent<HTMLFormElement>) => voidWhen provided, children render inside a <form id> and the footer submit button binds to it via the HTML form attribute (click + Enter submit, native validation runs).
formClassNamestringClass applied to the generated <form> wrapper (e.g. "space-y-4").
children*React.ReactNodeDialog body — form fields (when onSubmit is set), or arbitrary content for body-only dialogs.
footerReact.ReactNodeFully custom footer — overrides the built-in cancel/submit pair.
actionsAriaLabelstringAccessible name for the action cluster (role="group").
cancelLabelReact.ReactNodeCancel button label (i18n).
onCancel() => voidonCloseCalled when cancel is pressed.
cancelVariant"primary" | "secondary" | "ghost" | "danger"'secondary'Cancel button variant.
cancelDisabledbooleanDisable the cancel button.
submitLabelReact.ReactNodeSubmit button label (i18n).
submitDisabledbooleanDisable the submit button (e.g. while saving).
submitLoadingbooleanAsync submit — also disables the button, guarding double-submits.
submitVariant"primary" | "secondary" | "ghost" | "danger"'primary'Submit button variant — primary, the form-save convention.
actionsSize"sm" | "md" | "lg"'md'Size for both action buttons — md, the form-save convention.
Props
FormModal
openboolean—Whether the dialog is visible.
onClose() => void—Called when the dialog should close (ESC, scrim click).
titlestring—Heading rendered in the dialog header.
ariaLabelstring—Accessible name when no visible heading is used.
size"sm" | "md" | "lg" | "full"'md'Panel width — md, the form-dialog convention.
closeOnScrimClickbooleantrueSet false for flows that must be actioned explicitly.
idstring—Passed through to the Modal root as the id base — the dialog title id becomes `<id>-title` (referenced by aria-labelledby).
onSubmit(event: React.FormEvent<HTMLFormElement>) => void—When provided, children render inside a <form id> and the footer submit button binds to it via the HTML form attribute (click + Enter submit, native validation runs).
formClassNamestring—Class applied to the generated <form> wrapper (e.g. "space-y-4").
childrenReact.ReactNode—Dialog body — form fields (when onSubmit is set), or arbitrary content for body-only dialogs.
footerReact.ReactNode—Fully custom footer — overrides the built-in cancel/submit pair.
actionsAriaLabelstring—Accessible name for the action cluster (role="group").
cancelLabelReact.ReactNode—Cancel button label (i18n).
onCancel() => voidonCloseCalled when cancel is pressed.
cancelVariant"primary" | "secondary" | "ghost" | "danger"'secondary'Cancel button variant.
cancelDisabledboolean—Disable the cancel button.
submitLabelReact.ReactNode—Submit button label (i18n).
submitDisabledboolean—Disable the submit button (e.g. while saving).
submitLoadingboolean—Async submit — also disables the button, guarding double-submits.
submitVariant"primary" | "secondary" | "ghost" | "danger"'primary'Submit button variant — primary, the form-save convention.
actionsSize"sm" | "md" | "lg"'md'Size for both action buttons — md, the form-save convention.
<FormModal />Accessibility
Type
1 ruleARIA
1 ruleUsage
Import
import { FormModal } from "@epasslive/design-system"Example 1
<FormModal open={showForm} onClose={close} title={t('formTitle')} onSubmit={create} cancelLabel={t('cancel')} submitLabel={t('save')} submitDisabled={saving}><Input label={t('codeLabel')} required /></FormModal>Anti-patterns
— Do not hand-roll a <form> inside children together with onSubmit — the organism already generates the form wrapper (double nesting); without onSubmit a footer submit binds no form and will not submit
— Do not use for confirm/cancel-only dialogs — ConfirmModal is the destructive pattern