Skip to content

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>-input slot 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 new type values 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:

ts
/**
 * 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:

vue
<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:

ts
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 },
    ],
  },
];
vue
<DynamicForm :metadata="metadata" :template="ProjectFormTemplate" />

Notes ​

  • Collisions replace, not merge. Redeclaring a built-in type (for example text) or a built-in property (for example description) 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.
  • input and attributes are 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-input wins over default-input, so the same wrapper can also restyle one built-in control without touching the others.

Released under the MIT License.