Schema form dialog
A create or edit form in a modal, from the same field list the page version uses.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import SchemaFormDialog from '@shared/components/common/SchemaFormDialog.vue'
import { Button } from '@shared/components/ui/button'
import { ref } from 'vue'
const SCHEMA: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Category name', required: true, placeholder: 'Outdoor furniture' },
{ key: 'slug', type: 'text', label: 'Slug', required: true, placeholder: 'outdoor-furniture' },
{
key: 'parent',
type: 'select',
label: 'Parent category',
options: [
{ label: 'Garden', value: 'garden' },
{ label: 'Home', value: 'home' },
],
},
{ key: 'visible', type: 'switch', label: 'Visible in the shop' },
{ key: 'description', type: 'textarea', label: 'Description', grid: 12 },
]
const open = ref(false)
const values = ref<Record<string, unknown>>({ visible: true })
const saved = ref<Record<string, unknown> | null>(null)
const submitting = ref(false)
function onSubmit(next: Record<string, unknown>): void {
submitting.value = true
setTimeout(() => {
submitting.value = false
saved.value = next
open.value = false
}, 700)
}
</script>
<template>
<div class="space-y-3">
<Button @click="open = true">
New category
</Button>
<SchemaFormDialog
v-model:open="open"
v-model="values"
title="New category"
:schema="SCHEMA"
:submitting="submitting"
submit-label="Create category"
@submit="onSubmit"
/>
<pre v-if="saved" class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ saved }}</pre>
<p v-else class="text-sm text-muted-foreground">
Nothing saved yet
</p>
</div>
</template>Usage
It is a wrapper around SchemaForm, not a second implementation. Zod generation, visibleIf, server-error placement and the field grid all come from the same place, so the modal and the page cannot drift apart. See Schema for the field list and Forms for the engine.
v-model is the form's values and v-model:open is the dialog. submit carries the validated values; the dialog does not close itself, because whether it should depends on whether your request succeeded.
When a modal, and when a page
The rule the template follows: a create is a modal when its schema has no sections, meaning one group or none. Two or more sections stays a carded page.
That is not arbitrary. A schema author reaches for a second group exactly when the record has enough to it to need organizing, which is the same moment a dialog stops being the right container. Today that makes categories and roles modals, and the other modules pages.
The form inside
The dialog provides a form surface that is flat and footerless: one two-column grid, no section headings, and no footer of the form's own, because the dialog already owns one. Without that you would get two Cancel buttons.
Examples
Server errors
serverErrors takes { field, message }[] straight from a 422 and lands each message on its field, exactly as a page does.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import SchemaFormDialog from '@shared/components/common/SchemaFormDialog.vue'
import { Button } from '@shared/components/ui/button'
import { ref } from 'vue'
const SCHEMA: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Role name', required: true },
{ key: 'key', type: 'text', label: 'Key', required: true, placeholder: 'billing-manager' },
]
const open = ref(false)
const values = ref<Record<string, unknown>>({})
const serverErrors = ref<{ field: string, message: string }[]>([])
function onSubmit(): void {
serverErrors.value = [{ field: 'key', message: 'That key is already taken.' }]
}
</script>
<template>
<div class="space-y-3">
<Button @click="open = true; serverErrors = []">
New role
</Button>
<SchemaFormDialog
v-model:open="open"
v-model="values"
title="New role"
:schema="SCHEMA"
:server-errors="serverErrors"
@submit="onSubmit"
/>
<p class="text-sm text-muted-foreground">
Fill both fields and submit to see a 422 land on the Key field.
</p>
</div>
</template>API
Source: src/shared/components/common/SchemaFormDialog.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | required | You own it. |
title | string | required | The dialog's heading. |
description | string | — | Visually hidden. Present so the dialog has an accessible description. |
schema | FieldDefinition[] | required | The same field list a page would use. |
modelValue | Record<string, unknown> | {} | The form's values. |
submitting | boolean | false | Disables the submit button and swaps its label. |
submitLabel | string | 'common.save' | Defaults to "Save". |
submittingLabel | string | 'common.saving' | Defaults to "Saving…". |
serverErrors | { field: string, message: string }[] | — | Forwarded to the form. |
Emits
| Event | Payload | Description |
|---|---|---|
update:open | value: boolean | From Cancel, Escape and the overlay. |
update:modelValue | values: Record<string, unknown> | As the reader types. |
submit | values: Record<string, unknown> | After validation passes. |
Slots
None.
Accessibility
- Focus moves into the dialog, Tab is trapped inside it, and Escape closes it.
- The description is kept in the DOM as visually hidden text, so the dialog has an accessible description without a second line of copy under every title.
- The footer is a two-column grid of equal-width buttons with Save as the primary, and it uses logical margins, so it mirrors in a right-to-left layout.
- The content scrolls inside the dialog at up to 90% of the viewport height, so a long form never pushes its own footer off the screen.