Form dialog
A dialog holding a short form: title, a line of context, your fields, then Cancel and the submit verb.
'use client'
import { Button } from '@/components/button'
import { Checkbox, CheckboxField } from '@/components/checkbox'
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
import { FormDialog } from '@/components/form-dialog'
import { Textarea } from '@/components/textarea'
import { useEffect, useState } from 'react'
const maxLength = 2000
export default function Example() {
let [open, setOpen] = useState(false)
let [content, setContent] = useState('')
let [pinned, setPinned] = useState(false)
let [notes, setNotes] = useState<{ content: string; pinned: boolean }[]>([])
let remaining = maxLength - content.length
useEffect(() => {
if (open) {
setContent('')
setPinned(false)
}
}, [open])
return (
<>
<Button onClick={() => setOpen(true)}>Add note</Button>
<ul role="list">
{notes.map((note, index) => (
<li key={index}>{note.content}</li>
))}
</ul>
<FormDialog
open={open}
onClose={() => setOpen(false)}
onSubmit={() => {
let note = { content: content.trim(), pinned }
setNotes(pinned ? [note, ...notes] : [...notes, note])
setOpen(false)
}}
title="Add note"
description="Notes stay internal. The customer never sees them."
submitLabel="Add note"
submitDisabled={content.trim() === ''}
>
<FieldGroup>
<Field>
<Label>Note</Label>
<Textarea
name="content"
rows={5}
maxLength={maxLength}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="What should the team know about this customer?"
/>
{remaining <= 200 && <Description>{remaining} characters left.</Description>}
</Field>
<CheckboxField>
<Checkbox name="is_pinned" checked={pinned} onChange={setPinned} />
<Label>Pin this note</Label>
<Description>Pinned notes stay at the top of the list.</Description>
</CheckboxField>
</FieldGroup>
</FormDialog>
</>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import FieldGroup from '@/components/field-group.vue'
import FormDialog from '@/components/form-dialog.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
import { computed, reactive, ref, watch } from 'vue'
const maxLength = 2000
const open = ref(false)
const form = reactive({ content: '', pinned: false })
const notes = ref<{ content: string; pinned: boolean }[]>([])
watch(open, (value) => {
if (value) {
form.content = ''
form.pinned = false
}
})
const remaining = computed(() => maxLength - form.content.length)
function addNote() {
const note = { content: form.content.trim(), pinned: form.pinned }
notes.value = note.pinned ? [note, ...notes.value] : [...notes.value, note]
open.value = false
}
</script>
<template>
<Button @click="open = true">Add note</Button>
<ul role="list">
<li v-for="(note, index) in notes" :key="index">{{ note.content }}</li>
</ul>
<FormDialog
:open="open"
title="Add note"
description="Notes stay internal. The customer never sees them."
submit-label="Add note"
:submit-disabled="form.content.trim() === ''"
@close="open = false"
@submit="addNote"
>
<FieldGroup>
<Field>
<Label>Note</Label>
<Textarea
v-model="form.content"
name="content"
rows="5"
:maxlength="maxLength"
placeholder="What should the team know about this customer?"
/>
<Description v-if="remaining <= 200">{{ remaining }} characters left.</Description>
</Field>
<CheckboxField>
<Checkbox v-model="form.pinned" name="is_pinned" />
<Label>Pin this note</Label>
<Description>Pinned notes stay at the top of the list.</Description>
</CheckboxField>
</FieldGroup>
</FormDialog>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import type React from 'react'
import { Button } from './button'
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from './dialog'
export function FormDialog({
open,
onClose,
onSubmit,
title,
description,
badge,
size = 'lg',
submitLabel,
cancelLabel = 'Cancel',
submitDisabled = false,
color,
children,
}: {
open: boolean
onClose: () => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
title: React.ReactNode
description?: React.ReactNode
badge?: React.ReactNode
size?: React.ComponentProps<typeof Dialog>['size']
submitLabel: React.ReactNode
cancelLabel?: React.ReactNode
submitDisabled?: boolean
color?: React.ComponentProps<typeof Button>['color']
children: React.ReactNode
}) {
return (
<Dialog open={open} onClose={() => onClose()} size={size}>
{badge ? (
<div className="flex items-start justify-between gap-4">
<DialogTitle>{title}</DialogTitle>
{badge}
</div>
) : (
<DialogTitle>{title}</DialogTitle>
)}
{description && <DialogDescription>{description}</DialogDescription>}
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit(event)
}}
>
<DialogBody>{children}</DialogBody>
<DialogActions>
<Button plain onClick={onClose}>
{cancelLabel}
</Button>
<Button type="submit" color={color} disabled={submitDisabled}>
{submitLabel}
</Button>
</DialogActions>
</form>
</Dialog>
)
}<script setup lang="ts">
import Button from './button.vue'
import Dialog from './dialog.vue'
import DialogActions from './dialog-actions.vue'
import DialogBody from './dialog-body.vue'
import DialogDescription from './dialog-description.vue'
import DialogTitle from './dialog-title.vue'
type DialogSize = InstanceType<typeof Dialog>['$props']['size']
type ButtonColor = InstanceType<typeof Button>['$props']['color']
withDefaults(
defineProps<{
open: boolean
title: string
description?: string
size?: DialogSize
submitLabel: string
cancelLabel?: string
submitDisabled?: boolean
color?: ButtonColor
}>(),
{ size: 'lg', cancelLabel: 'Cancel', submitDisabled: false }
)
const emit = defineEmits<{ close: []; submit: [event: Event] }>()
</script>
<template>
<Dialog :open="open" :size="size" @close="emit('close')">
<div v-if="$slots.badge" class="flex items-start justify-between gap-4">
<DialogTitle>{{ title }}</DialogTitle>
<slot name="badge" />
</div>
<DialogTitle v-else>{{ title }}</DialogTitle>
<DialogDescription v-if="description">{{ description }}</DialogDescription>
<form @submit.prevent="emit('submit', $event)">
<DialogBody>
<slot />
</DialogBody>
<DialogActions>
<Button plain @click="emit('close')">{{ cancelLabel }}</Button>
<Button type="submit" :color="color" :disabled="submitDisabled">{{ submitLabel }}</Button>
</DialogActions>
</form>
</Dialog>
</template>Component API
| Prop | Default | Description |
|---|---|---|
FormDialogbuilt on the <Dialog> component | ||
open | - | Whether the dialog is open. |
onClose | - | Called on Cancel, Escape and outside click. |
onSubmit | - | Called when the form is submitted, by the button or Enter in a field, once native validation passes. Receives the submit event, default already prevented. |
title | - | What the form does: "Add note", "Edit address". |
description | - | One line of context under the title. |
badge | - | Rendered opposite the title: a step counter, the type of the record. |
size | lg | Forwarded to the Dialog. `xl` for two-column forms. |
submitLabel | - | Verb and object: "Add note", "Save changes". |
cancelLabel | Cancel | Label of the plain button. |
submitDisabled | false | Disable submit until the required fields are filled. |
color | - | Color of the submit button, the default button when unset. `red` for a destructive form. |
| Prop | Default | Description |
|---|---|---|
FormDialogbuilt on the <Dialog> component | ||
open | - | Whether the dialog is open. |
@close | - | Emitted on Cancel, Escape and outside click. |
@submit | - | Emitted when the form is submitted, by the button or Enter in a field, once native validation passes. Receives the submit event, default already prevented. |
title | - | What the form does: "Add note", "Edit address". |
description | - | One line of context under the title. |
#badge | - | Slot rendered opposite the title: a step counter, the type of the record. |
size | lg | Forwarded to the Dialog. `xl` for two-column forms. |
submitLabel | - | Verb and object: "Add note", "Save changes". |
cancelLabel | Cancel | Label of the plain button. |
submitDisabled | false | Disable submit until the required fields are filled. |
color | - | Color of the submit button, the default button when unset. `red` for a destructive form. |
Examples
Basic example
The fields go in the default slot, in a FieldGroup. Reset them when open turns true, and disable submit until the required ones are filled. A character counter appears only in the last tenth of the limit:
'use client'
import { Button } from '@/components/button'
import { Checkbox, CheckboxField } from '@/components/checkbox'
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
import { FormDialog } from '@/components/form-dialog'
import { Textarea } from '@/components/textarea'
import { useEffect, useState } from 'react'
const maxLength = 2000
export default function Example() {
let [open, setOpen] = useState(false)
let [content, setContent] = useState('')
let [pinned, setPinned] = useState(false)
let [notes, setNotes] = useState<{ content: string; pinned: boolean }[]>([])
let remaining = maxLength - content.length
useEffect(() => {
if (open) {
setContent('')
setPinned(false)
}
}, [open])
return (
<>
<Button onClick={() => setOpen(true)}>Add note</Button>
<ul role="list">
{notes.map((note, index) => (
<li key={index}>{note.content}</li>
))}
</ul>
<FormDialog
open={open}
onClose={() => setOpen(false)}
onSubmit={() => {
let note = { content: content.trim(), pinned }
setNotes(pinned ? [note, ...notes] : [...notes, note])
setOpen(false)
}}
title="Add note"
description="Notes stay internal. The customer never sees them."
submitLabel="Add note"
submitDisabled={content.trim() === ''}
>
<FieldGroup>
<Field>
<Label>Note</Label>
<Textarea
name="content"
rows={5}
maxLength={maxLength}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="What should the team know about this customer?"
/>
{remaining <= 200 && <Description>{remaining} characters left.</Description>}
</Field>
<CheckboxField>
<Checkbox name="is_pinned" checked={pinned} onChange={setPinned} />
<Label>Pin this note</Label>
<Description>Pinned notes stay at the top of the list.</Description>
</CheckboxField>
</FieldGroup>
</FormDialog>
</>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import FieldGroup from '@/components/field-group.vue'
import FormDialog from '@/components/form-dialog.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
import { computed, reactive, ref, watch } from 'vue'
const maxLength = 2000
const open = ref(false)
const form = reactive({ content: '', pinned: false })
const notes = ref<{ content: string; pinned: boolean }[]>([])
watch(open, (value) => {
if (value) {
form.content = ''
form.pinned = false
}
})
const remaining = computed(() => maxLength - form.content.length)
function addNote() {
const note = { content: form.content.trim(), pinned: form.pinned }
notes.value = note.pinned ? [note, ...notes.value] : [...notes.value, note]
open.value = false
}
</script>
<template>
<Button @click="open = true">Add note</Button>
<ul role="list">
<li v-for="(note, index) in notes" :key="index">{{ note.content }}</li>
</ul>
<FormDialog
:open="open"
title="Add note"
description="Notes stay internal. The customer never sees them."
submit-label="Add note"
:submit-disabled="form.content.trim() === ''"
@close="open = false"
@submit="addNote"
>
<FieldGroup>
<Field>
<Label>Note</Label>
<Textarea
v-model="form.content"
name="content"
rows="5"
:maxlength="maxLength"
placeholder="What should the team know about this customer?"
/>
<Description v-if="remaining <= 200">{{ remaining }} characters left.</Description>
</Field>
<CheckboxField>
<Checkbox v-model="form.pinned" name="is_pinned" />
<Label>Pin this note</Label>
<Description>Pinned notes stay at the top of the list.</Description>
</CheckboxField>
</FieldGroup>
</FormDialog>
</template>Two-column form
Use size="xl" and a grid gap-6 sm:grid-cols-2 inside the FieldGroup for address-like forms:
<FormDialog size="xl" title="Add address" submitLabel="Add address" submitDisabled={!canSubmit} …>
<FieldGroup>
<Field>
<Label>Address line 1</Label>
<Input name="line1" autoComplete="address-line1" placeholder="123 Main Street" />
</Field>
<Field>
<Label>Address line 2</Label>
<Input name="line2" autoComplete="address-line2" />
</Field>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<Label>City</Label>
<Input name="city" />
</Field>
<Field>
<Label>State or region</Label>
<Input name="state" />
</Field>
<Field>
<Label>Postal code</Label>
<Input name="postal_code" />
</Field>
<Field>
<Label>Country</Label>
<Input name="country" />
</Field>
</div>
</FieldGroup>
</FormDialog><template>
<FormDialog size="xl" title="Add address" submit-label="Add address" :submit-disabled="!canSubmit" …>
<FieldGroup>
<Field>
<Label>Address line 1</Label>
<Input v-model="form.line1" name="line1" autocomplete="address-line1" placeholder="123 Main Street" />
</Field>
<Field>
<Label>Address line 2</Label>
<Input v-model="form.line2" name="line2" autocomplete="address-line2" />
</Field>
<div class="grid gap-6 sm:grid-cols-2">
<Field>
<Label>City</Label>
<Input v-model="form.city" name="city" />
</Field>
<Field>
<Label>State or region</Label>
<Input v-model="form.state" name="state" />
</Field>
<Field>
<Label>Postal code</Label>
<Input v-model="form.postalCode" name="postal_code" />
</Field>
<Field>
<Label>Country</Label>
<Input v-model="form.country" name="country" />
</Field>
</div>
</FieldGroup>
</FormDialog>
</template>With a badge and a notice
The badge slot sits opposite the title, for a step counter. Each submit moves to the next step and swaps the fields and the submit label:
<FormDialog
size="xl"
title="Change email address"
description="Enter the address you want to use for signing in and notifications."
badge={<Badge color="zinc" className="shrink-0">Step {step} of 3</Badge>}
submitLabel={step === 1 ? 'Send verification code' : step === 2 ? 'Continue' : 'Change email'}
onSubmit={() => (step < 3 ? setStep(step + 1) : setOpen(false))}
…
>
{step === 1 ? (
<>
<Notice icon={EnvelopeIcon}>
For security, we will send verification codes to both your current and new addresses.
</Notice>
<Field className="mt-6">
<Label>New email address</Label>
<Input type="email" name="new_email" autoComplete="email" required />
</Field>
</>
) : (
<Field key={step}>
<Label>{step === 2 ? 'Code sent to your current address' : 'Code sent to your new address'}</Label>
<CodeInput value={code} onChange={setCode} className="mt-3" />
</Field>
)}
</FormDialog><template>
<FormDialog
size="xl"
title="Change email address"
description="Enter the address you want to use for signing in and notifications."
:submit-label="step === 1 ? 'Send verification code' : step === 2 ? 'Continue' : 'Change email'"
@submit="step < 3 ? step++ : (open = false)"
…
>
<template #badge>
<Badge color="zinc" class="shrink-0">Step {{ step }} of 3</Badge>
</template>
<template v-if="step === 1">
<Notice :icon="EnvelopeIcon">
For security, we will send verification codes to both your current and new addresses.
</Notice>
<Field class="mt-6">
<Label>New email address</Label>
<Input type="email" name="new_email" autocomplete="email" required />
</Field>
</template>
<Field v-else :key="step">
<Label>{{ step === 2 ? 'Code sent to your current address' : 'Code sent to your new address' }}</Label>
<CodeInput v-model="code" class="mt-3" />
</Field>
</FormDialog>
</template>Destructive form
A confirmation that asks for a reason is a form, not an alert. Keep the question as the title and turn the submit button red:
<FormDialog
color="red"
title="Block Mariam Keita?"
description="Payments and payment links stop working for this customer right away. Existing transactions are untouched, and you can unblock them at any time."
submitLabel="Block customer"
…
>
<Field>
<Label>Reason</Label>
<Textarea name="reason" rows={3} maxLength={500} placeholder="Chargeback pattern, fraud alert, customer request…" />
<Description>Optional, but it is what your team reads on the blocked customer.</Description>
</Field>
</FormDialog><template>
<FormDialog
color="red"
title="Block Mariam Keita?"
description="Payments and payment links stop working for this customer right away. Existing transactions are untouched, and you can unblock them at any time."
submit-label="Block customer"
…
>
<Field>
<Label>Reason</Label>
<Textarea name="reason" rows="3" :maxlength="500" placeholder="Chargeback pattern, fraud alert, customer request…" />
<Description>Optional, but it is what your team reads on the blocked customer.</Description>
</Field>
</FormDialog>
</template>Password guard
For a step-up confirmation, the submit stays disabled until the password is typed:
<FormDialog
size="md"
color="red"
title="Sign out other sessions?"
description="Enter your password to confirm you want to sign out of all your other sessions. This device stays signed in."
submitLabel="Sign out other sessions"
submitDisabled={!password}
…
>
<Field>
<Label>Password</Label>
<Input type="password" name="password" autoComplete="current-password" />
</Field>
</FormDialog><template>
<FormDialog
size="md"
color="red"
title="Sign out other sessions?"
description="Enter your password to confirm you want to sign out of all your other sessions. This device stays signed in."
submit-label="Sign out other sessions"
:submit-disabled="!password"
…
>
<Field>
<Label>Password</Label>
<Input v-model="password" type="password" name="password" autocomplete="current-password" />
</Field>
</FormDialog>
</template>