Skip to content

FormBuilder Component ​

FormBuilder defines a responsive Vuetify grid layout in code instead of a template, e.g. for a layout that comes from a backend or is generated by the application. <form-render> renders it.

Basic Usage ​

Basic Form Layout Example

Personal Information

Contact Information

Additional Information

Features ​

API Usage ​

Using simple() ​

simple(cols) places each component in a 12 / cols-wide column and starts a new row after cols components:

typescript
const form = new FormBuilder();

// Two equal columns; each dfInput() call fills one column
form.simple(2)
  .dfInput({ label: 'First name', placeholder: 'Enter your first name' })
  .dfInput({ label: 'Last name', placeholder: 'Enter your last name' });

// Switch to a single full-width column
form.simple()
  .generic('VTextarea', { label: 'Comments', placeholder: 'Enter any additional comments', rows: 3 });

Using row() / col() (more control) ​

row() / col() set column widths and row and column props explicitly:

typescript
const form = new FormBuilder();

// A row with two columns of different widths
form.row({ }, (row) => {
  row.col({ cols: 8 }, (col) => col
    .component((cmpt) => cmpt
      .dfInput({ label: 'Street', placeholder: 'Enter street name' })));
  row.col({ cols: 4 }, (col) => col
    .component((cmpt) => cmpt
      .dfInput({ label: 'House number' })));
  return row;
});

// A second row with a full-width text area
form.row({ }, (row) => {
  row.col({ cols: 12 }, (col) => col
    .component((cmpt) => cmpt
      .generic('VTextarea', { label: 'Comments', rows: 3 })));
  return row;
});

Nested Forms ​

nestedForm() embeds one layout in another:

typescript
const addressForm = new FormBuilder();
addressForm
  .row({ }, (row) => row
    .col({ cols: 8 }, (col) => col.component((cmpt) => cmpt.generic('VTextField', { label: 'Street' })))
    .col({ cols: 4 }, (col) => col.component((cmpt) => cmpt.generic('VTextField', { label: 'Number' }))));

// Include the address form within the main form
mainForm.row({ }, (row) => row
  .col({ cols: 12 }, (col) => col
    .component((cmpt) => cmpt
      .nestedForm(addressForm))));

toJSON() serializes the nested layout with the outer one. <FormRender> renders it as a separate form inside its column, which resolves its own breakpoints and uses the outer :components map.

Template-declared content (Teleport) ​

A cell's content is declared in one of two ways:

  • In code - .generic(), .dfInput() and the other component builder methods. The name is resolved when the layout renders, against <FormRender :components>.
  • In the template - the layout holds only an anchor in the cell. The component, its v-model and its event handlers are written in the application's template and moved into the cell with Vue's <Teleport>.

teleportAnchor(idRef) adds a <div :id> to the cell through .generic(). useTeleportAnchor() returns the id / target pair for teleportAnchor() and <Teleport :to>:

typescript
const email = ref('');
const emailAnchor = useTeleportAnchor();

formBuilder.row({ }, (row) => row
  .col({ cols: 6 }, (col) => col
    .component((cmpt) => cmpt.teleportAnchor(emailAnchor.id))));
html
<Teleport :to="emailAnchor.target.value" defer>
  <v-text-field v-model="email" label="Email" type="email" />
</Teleport>

useTeleportAnchors(['email', 'phone']) returns { email, phone }, an anchor each.

.value is required: emailAnchor is a plain object, and Vue unwraps only refs that are top-level properties of the render context. :to="emailAnchor.target" binds the ComputedRef object, and <Teleport> finds no target.

An anchor's id exists only in the application that generated it. A layout with anchors serializes through toJSON() / fromJSON() like any other, but renders correctly only in that application, as is the case for the components map. A layout that comes from a backend or is otherwise serialized uses code-declared content; teleport anchors suit a layout built and rendered by the same component.

defer lets the <Teleport> mount before or after the anchor in the same render.

Under server-side rendering the <Teleport> renders after mount - see Server-side rendering.

The same field across breakpoints ​

One breakpoint's layout renders at a time, so a field declared at several breakpoints passes the same idRef to each teleportAnchor() call. teleportAnchor() writes an id into an empty ref and keeps one that is already there, so the id, and the <Teleport :to> that targets it, stay the same across breakpoints:

typescript
formBuilder.row({ }, (row) => row
  .col({ cols: 6 }, (col) => col.component((cmpt) => cmpt.teleportAnchor(emailAnchor.id))));

formBuilder.breakpoint('sm', (form) => form.row({ }, (row) => row
  .col({ cols: 12 }, (col) => col.component((cmpt) => cmpt.teleportAnchor(emailAnchor.id)))));

The element with the id can still change: when a breakpoint moves the field to another row or column, Vue removes the old <div :id> and creates a new one. <Teleport :to> resolves its target again only when the to string changes, so it still points at the removed element and its content is not shown.

Keying the <Teleport> on <FormRender>'s breakpoint emit remounts it, and it resolves the new element:

typescript
const breakpoint = ref();
html
<form-render :layout="formBuilder" :components="components" @breakpoint="breakpoint = $event" />
<Teleport :key="breakpoint" :to="emailAnchor.target.value" defer>
  <v-text-field v-model="email" label="Email" type="email" />
</Teleport>

The same ref given to two different fields gives both anchors one id in a layout that renders both. <Teleport :to="'#' + id"> reaches only the first element with that id, and the other anchor stays empty; <FormRender> logs a warning for it.

The Contact Information section of the demo at the top of this page uses teleport anchors keyed on the breakpoint.

See also ​

Examples ​

Registration Form ​

typescript
const form = new FormBuilder();

form
  .simple() // single-column layout if no parameter is given
  .generic('h3', { [FormBuilderBodyProp]: 'Personal Information', class: 'mt-0' })
  .simple(2)
  .generic('VTextField', { label: 'First Name', modelValue: '' })
  .generic('VTextField', { label: 'Last Name', modelValue: '' })
  .simple()
  .generic('h3', { [FormBuilderBodyProp]: 'Contact Information', class: 'mt-0' })
  .simple(2)
  .generic('VTextField', { label: 'Email', type: 'email', modelValue: '' })
  .generic('VSelect', {
    label: 'Preferred Contact Method',
    items: [
      { title: 'Email', value: 'email' },
      { title: 'Phone', value: 'phone' },
      { title: 'Mail', value: 'mail' }
    ],
    modelValue: '',
  })
  .simple()
  .generic('h3', { [FormBuilderBodyProp]: 'Additional Information', class: 'mt-0' })
  .generic('VTextarea', { label: 'Comments', rows: 3, modelValue: '' });

// The same layout, declared with row() / col():

const sameForm = new FormBuilder();

sameForm
  .row({ }, (row) => {
    row.col({ cols: 12 }, (col) => col
      .component((cmpt) => cmpt
        .generic('h3', { [FormBuilderBodyProp]: 'Personal Information', class: 'mt-0' })));
    return row;
  })
  .row({ }, (row) => {
    row.col({ cols: 6 }, (col) => col
      .component((cmpt) => cmpt
        .generic('VTextField', { label: 'First Name', modelValue: '' })));
    row.col({ cols: 6 }, (col) => col
      .component((cmpt) => cmpt
        .generic('VTextField', { label: 'Last Name', modelValue: '' })));
    return row;
  })
  // Contact Information Section
  .row({ }, (row) => {
    row.col({ cols: 12 }, (col) => col
      .component((cmpt) => cmpt
        .generic('h3', { [FormBuilderBodyProp]: 'Contact Information', class: 'mt-0' })));
    return row;
  })
  .row({ }, (row) => {
    row.col({ cols: 6 }, (col) => col
      .component((cmpt) => cmpt
        .generic('VTextField', { label: 'Email', type: 'email', modelValue: '' })));
    row.col({ cols: 6 }, (col) => col
      .component((cmpt) => cmpt
        .generic('VSelect', {
          label: 'Preferred Contact Method',
          items: [
            { title: 'Email', value: 'email' },
            { title: 'Phone', value: 'phone' },
            { title: 'Mail', value: 'mail' }
          ],
          modelValue: '',
        })));
    return row;
  })
  // Additional Information Section
  .row({ }, (row) => {
    row.col({ cols: 12 }, (col) => col
      .component((cmpt) => cmpt
        .generic('h3', { [FormBuilderBodyProp]: 'Additional Information', class: 'mt-0' })));
    return row;
  })
  .row({ }, (row) => {
    row.col({ cols: 12 }, (col) => col
      .component((cmpt) => cmpt
        .generic('VTextarea', { label: 'Comments', rows: 3, modelValue: '' })));
    return row;
  });

Released under the MIT License.