Extending The Starter Template
The starter template ships a complete set of controls, but every project has a few fields of its own. This example adds two project-specific field types on top of the starter catalogue: a multi-line textarea and a star rating. Everything else (the chrome, headings, validation display, all other field types) keeps the starter rendering.
What It Demonstrates
- Declaring extra field types and field properties with
extendMetadata - Supplying the
<type>-inputslot for each new type through a wrapper component - The new controls rendering inside the starter's built-in field chrome (label, description, error message)
- Full type safety:
rows,maxRating, and the newtypevalues are checked in your metadata
Example
Project review
IsDirty: false
Touched: false
Valid: true
// form values:
{
"review": {}
}
1. Declare the field types
extendMetadata layers your own field types and properties on top of the built-in catalogue. It is purely a type carrier: nothing is merged at runtime. Keep it in a .ts file next to the template so pages can import the Metadata type without pulling in the component:
/**
* The starter catalogue plus the field types this project needs:
* a multi-line `textarea` and a star `rating`.
*/
export const projectMetadata = extendMetadata<
{
textarea: string
rating: number
},
{
/** Visible rows of the textarea control. */
rows?: number
/** Highest selectable rating. */
maxRating?: number
}
>();
export type Metadata = GetMetadataType<typeof projectMetadata>;
export type Settings = GetDynamicFormSettingsType<typeof projectMetadata>;2. Wrap the starter template
The wrapper passes the extended configuration via metadataConfiguration and adds one <type>-input slot per new field type. The #input and #attributes slots must always be forwarded: they hand the engine's field and attribute renders to the starter's built-in chrome.
Because the slots are named textarea-input and rating-input, the new controls render inside the same field chrome as every built-in control. The label, description, required tag, and error message all come for free:
<script lang="ts" setup>
import { StarterFormTemplate } from '@bach.software/vue-dynamic-form-starter';
import { projectMetadata } from './ProjectFormTemplate';
</script>
<template>
<StarterFormTemplate :metadataConfiguration="projectMetadata">
<!-- Hands the engine's field and attribute renders to the built-in chrome. -->
<template #input="s">
<slot v-bind="s" />
</template>
<template #attributes="s">
<slot name="attributes" v-bind="s" />
</template>
<template #textarea-input="{ fieldContext, fieldMetadata, disabled }">
<textarea
:id="fieldMetadata.path"
class="sft-input"
:rows="fieldMetadata.rows ?? 4"
:value="fieldContext.value.value"
:placeholder="fieldMetadata.placeholder"
:disabled="disabled || fieldMetadata.disabled"
@input="fieldContext.handleChange(($event.target as HTMLTextAreaElement).value)"
@blur="fieldContext.handleBlur"
/>
</template>
<template #rating-input="{ fieldContext, fieldMetadata, disabled }">
<div class="ratingInput" role="radiogroup">
<button
v-for="star in fieldMetadata.maxRating ?? 5"
:key="star"
type="button"
role="radio"
class="ratingStar"
:class="{ isActive: star <= (fieldContext.value.value ?? 0) }"
:aria-checked="star === fieldContext.value.value"
:aria-label="`${star} of ${fieldMetadata.maxRating ?? 5}`"
:disabled="disabled || fieldMetadata.disabled"
@click="fieldContext.handleChange(star)"
@blur="fieldContext.handleBlur"
>
★
</button>
</div>
</template>
</StarterFormTemplate>
</template>
<style scoped>
.ratingInput {
display: flex;
gap: 0.25rem;
}
.ratingStar {
background: none;
border: none;
padding: 0;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--sft-input-border, #d4d4d8);
}
.ratingStar.isActive {
color: #f59e0b;
}
</style>3. Use the new types in metadata
Pass the wrapper as the template and use the new types like any built-in one. TypeScript checks rows and maxRating because the Metadata type comes from the extended configuration:
const metadata: Metadata[] = [
{
name: 'review',
type: 'heading',
fieldOptions: { label: 'Project review' },
children: [
{ name: 'projectName', type: 'text', fieldOptions: { label: 'Project name' }, placeholder: 'e.g. Apollo' },
{ name: 'rating', type: 'rating', fieldOptions: { label: 'Overall rating' }, maxRating: 5 },
{ name: 'summary', type: 'textarea', fieldOptions: { label: 'What went well?' }, rows: 4, colSpan: 'full', minOccurs: 0 },
],
},
];<DynamicForm :metadata="metadata" :template="ProjectFormTemplate" />Notes
- Collisions replace, not merge. Redeclaring a built-in type (for example
text) or a built-in property (for exampledescription) replaces the built-in declaration. Supply the matching slot as well in that case, because the built-in markup still assumes the built-in property shape. inputandattributesare reserved. Field types with these names are not supported; they are the engine-carried slots the wrapper forwards.- Overriding built-ins works the same way. A dedicated per-type slot such as
text-inputwins overdefault-input, so the same wrapper can also restyle one built-in control without touching the others.