Skip to main content

FormModal

Implemented

Form-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).

Categoryorganisms
Sourcecomponents/organisms/FormModal/FormModal.tsx
AI HintUse for create/edit forms in a dialog. onSubmit wraps children in a real form; the footer submit binds by id, so required-field validation and Enter-to-submit work. Without action labels it degrades to a body-only content dialog.

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

Prop
Type
Default
Description
open*
boolean
—

Whether the dialog is visible.

onClose*
() => void
—

Called when the dialog should close (ESC, scrim click).

title
string
—

Heading rendered in the dialog header.

ariaLabel
string
—

Accessible name when no visible heading is used.

size
"sm" | "md" | "lg" | "full"
'md'

Panel width — md, the form-dialog convention.

closeOnScrimClick
boolean
true

Set false for flows that must be actioned explicitly.

id
string
—

Passed through to the Modal root as the id base — the dialog title id becomes `<id>-title` (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).

formClassName
string
—

Class applied to the generated <form> wrapper (e.g. "space-y-4").

children*
React.ReactNode
—

Dialog body — form fields (when onSubmit is set), or arbitrary content for body-only dialogs.

footer
React.ReactNode
—

Fully custom footer — overrides the built-in cancel/submit pair.

actionsAriaLabel
string
—

Accessible name for the action cluster (role="group").

cancelLabel
React.ReactNode
—

Cancel button label (i18n).

onCancel
() => void
onClose

Called when cancel is pressed.

cancelVariant
"primary" | "secondary" | "ghost" | "danger"
'secondary'

Cancel button variant.

cancelDisabled
boolean
—

Disable the cancel button.

submitLabel
React.ReactNode
—

Submit button label (i18n).

submitDisabled
boolean
—

Disable the submit button (e.g. while saving).

submitLoading
boolean
—

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.

Props

Preview

FormModal

Prop
Type
Required
Default
Description
open
boolean
Required
—

Whether the dialog is visible.

onClose
() => void
Required
—

Called when the dialog should close (ESC, scrim click).

title
string
Optional
—

Heading rendered in the dialog header.

ariaLabel
string
Optional
—

Accessible name when no visible heading is used.

size
"sm" | "md" | "lg" | "full"
Optional
'md'

Panel width — md, the form-dialog convention.

closeOnScrimClick
boolean
Optional
true

Set false for flows that must be actioned explicitly.

id
string
Optional
—

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
Optional
—

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).

formClassName
string
Optional
—

Class applied to the generated <form> wrapper (e.g. "space-y-4").

children
React.ReactNode
Required
—

Dialog body — form fields (when onSubmit is set), or arbitrary content for body-only dialogs.

footer
React.ReactNode
Optional
—

Fully custom footer — overrides the built-in cancel/submit pair.

actionsAriaLabel
string
Optional
—

Accessible name for the action cluster (role="group").

cancelLabel
React.ReactNode
Optional
—

Cancel button label (i18n).

onCancel
() => void
Optional
onClose

Called when cancel is pressed.

cancelVariant
"primary" | "secondary" | "ghost" | "danger"
Optional
'secondary'

Cancel button variant.

cancelDisabled
boolean
Optional
—

Disable the cancel button.

submitLabel
React.ReactNode
Optional
—

Submit button label (i18n).

submitDisabled
boolean
Optional
—

Disable the submit button (e.g. while saving).

submitLoading
boolean
Optional
—

Async submit — also disables the button, guarding double-submits.

submitVariant
"primary" | "secondary" | "ghost" | "danger"
Optional
'primary'

Submit button variant — primary, the form-save convention.

actionsSize
"sm" | "md" | "lg"
Optional
'md'

Size for both action buttons — md, the form-save convention.

Quick use:<FormModal />

Accessibility

ARIA roledialog

Type

1 rule

NASA Rule 6

submit binds to the generated <form> via the HTML form attribute — Enter + click submit, native validation runs (NASA Rule 6)

ARIA

1 rule

WCAG 4.1.2

Dialog chrome from Modal molecule (role=dialog, labelledby title, ESC + focus trap); action cluster exposes role=group with actionsAriaLabel

Usage

Import

tsxSnippet
import { FormModal } from "@epasslive/design-system"

Example 1

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

Design System v4.1.0 | ePassLive