Form page layout
The shell every add and edit page uses: a card, a form contract and a footer.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import SchemaForm from '@core/schema/SchemaForm.vue'
import FormPageLayout from '@shared/components/common/pages/FormPageLayout.vue'
import { ref } from 'vue'
const SCHEMA: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Full name', required: true },
{ key: 'email', type: 'email', label: 'Email', required: true },
{ key: 'role', type: 'select', label: 'Role', options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
{ label: 'Viewer', value: 'viewer' },
] },
{ key: 'active', type: 'switch', label: 'Active' },
]
const values = ref<Record<string, unknown>>({ active: true })
const saved = ref<Record<string, unknown> | null>(null)
</script>
<template>
<FormPageLayout title="New user">
<SchemaForm v-model="values" :schema="SCHEMA" @submit="saved = $event" />
<template v-if="saved" #actions>
<p class="text-sm text-muted-foreground">
Saved {{ saved.name }}
</p>
</template>
</FormPageLayout>
</template>Usage
Put a SchemaForm in the default slot and the layout does the rest: the header, the card around the form, and the form surface that gives the form its footer.
The layout provides a form surface, which is how it tells the form what shape to take. By default that surface is flat with a footer: one two-column grid, no section headings, and the Save and Cancel bar drawn by the form itself. That is why the add and edit buttons are not in this layout.
Examples
Keep the schema's own structure
structured stops the surface flattening the form, so a schema's group headings and per-field grid spans are honored. The card and the footer stay.
The flat default is right for the add and edit pages that ship, and it stays the default, so a page that says nothing gets what it always got. Without structured, a schema that declares sections would lose them silently.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import SchemaForm from '@core/schema/SchemaForm.vue'
import FormPageLayout from '@shared/components/common/pages/FormPageLayout.vue'
import { ref } from 'vue'
const SCHEMA: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Full name', required: true, group: 'Person', grid: 6 },
{ key: 'email', type: 'email', label: 'Email', required: true, group: 'Person', grid: 6 },
{ key: 'company', type: 'text', label: 'Company', group: 'Account', grid: 6 },
{ key: 'seats', type: 'number', label: 'Seats', group: 'Account', grid: 6 },
{ key: 'notes', type: 'textarea', label: 'Notes', group: 'Account', grid: 12 },
]
const values = ref<Record<string, unknown>>({})
</script>
<template>
<FormPageLayout title="New account" structured>
<SchemaForm v-model="values" :schema="SCHEMA" />
</FormPageLayout>
</template>A shell, not a form
bare drops the card and the form contract, for a page that uses this layout for its header and then renders panels that bring their own surfaces. Settings does exactly that; carding it would draw a box around three boxes.
<script setup lang="ts">
import FormPageLayout from '@shared/components/common/pages/FormPageLayout.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared/components/ui/tabs'
</script>
<template>
<FormPageLayout title="Settings" bare>
<Tabs default-value="general" class="space-y-4">
<TabsList>
<TabsTrigger value="general">
General
</TabsTrigger>
<TabsTrigger value="billing">
Billing
</TabsTrigger>
</TabsList>
<TabsContent value="general">
<Card>
<CardHeader>
<CardTitle>Workspace</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Each tab panel brings its own card, so the layout must not draw one around them.
</CardContent>
</Card>
</TabsContent>
<TabsContent value="billing">
<Card>
<CardHeader>
<CardTitle>Plan</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Team, billed yearly.
</CardContent>
</Card>
</TabsContent>
</Tabs>
</FormPageLayout>
</template>The surface, in one table
| Props | flat | footer | Use it for |
|---|---|---|---|
| none | yes | yes | The usual add or edit page. |
structured | no | yes | A schema with sections or a deliberate grid. |
bare | no | no | A page that is a shell, not a form. |
API
Source: src/shared/components/common/pages/FormPageLayout.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Forwarded to the page header. |
bare | boolean | false | No card, no form contract. |
structured | boolean | false | Keep the schema's sections and grid. |
Emits
None.
Slots
| Slot | Description |
|---|---|
breadcrumb | Forwarded to the page header. |
header-actions | Forwarded to the page header's actions. |
default | The form, or whatever the page is. |
actions | A row under the card, for an action the form does not own, such as a download. |
Note the two action slots. header-actions goes in the header; actions goes under the card. The Save and Cancel buttons belong to neither: they are the form's footer.
Accessibility
- The card clips its own corners, so the footer's full-bleed rule follows the card's radius rather than squaring off its bottom.
- The footer is inside the form element, which is what keeps its submit button wired to the form and working from the keyboard.