Skip to content

Field components ​

The adapters render each field with your component for its type. Below you will learn what each component gets, and how to write one for every built-in type.

Components by type ​

components maps each field type to a component, by type name. Several types can share one:

ts
import type { FieldComponents } from '@whatsaaaa/form-builder-vue'
import CheckboxInput from './CheckboxInput.vue'
import GroupFieldset from './GroupFieldset.vue'
import FileInput from './FileInput.vue'
import FormHeading from './FormHeading.vue'
import ListRepeater from './ListRepeater.vue'
import SelectInput from './SelectInput.vue'
import TextInput from './TextInput.vue'

export const components: FieldComponents = {
  text: TextInput,
  email: TextInput,
  number: TextInput,
  checkbox: CheckboxInput,
  select: SelectInput,
  radio: SelectInput,
  file: FileInput,
  group: GroupFieldset,
  array: ListRepeater,
  heading: FormHeading,
  // ...one for every type your definitions use
}
ts
import type { FieldComponents } from '@whatsaaaa/form-builder-react'
import { CheckboxInput } from './CheckboxInput'
import { GroupFieldset } from './GroupFieldset'
import { FileInput } from './FileInput'
import { FormHeading } from './FormHeading'
import { ListRepeater } from './ListRepeater'
import { SelectInput } from './SelectInput'
import { TextInput } from './TextInput'

export const components: FieldComponents = {
  text: TextInput,
  email: TextInput,
  number: TextInput,
  checkbox: CheckboxInput,
  select: SelectInput,
  radio: SelectInput,
  file: FileInput,
  group: GroupFieldset,
  array: ListRepeater,
  heading: FormHeading,
  // ...one for every type your definitions use
}

Pass it to useJsonForm, or to <JsonFields> and <JsonField> as a prop. A type that a definition uses but components doesn't have throws a TypeError that names every missing type when <JsonFields> or <JsonField> first renders. Only the fields it renders count, and their fields at any depth, so a missing component shows up at once, not when its field first appears, like in a new list item.

Roles ​

What a component gets depends on the field's role:

RoleWhich fieldsProps
valueevery field with a valuefield, path, kind, required, input, errors, inputProps, setInput
groupa group, or a container of kind objectfield, path, and the group's fields
arraya list, or a container of kind arrayfield, path, items, errors, add, remove, move, and each item's fields
displaya heading, a paragraph, a dividerfield

In Vue, a group renders its fields in its default slot, and a list each item's fields in its default slot, with { index }. In React, a group gets them as children, and a list as children(index). Your own types render through the role of their kind.

Value fields ​

A value field gets the field from the definition, its state in Formisch, and a setter:

vue
<script setup lang="ts">
import type { ValueFieldProps } from '@whatsaaaa/form-builder-vue'

defineProps<ValueFieldProps>()

// The native input for each type. The rest are text.
const inputTypes: Record<string, string> = {
  email: 'email',
  password: 'password',
  number: 'number',
  url: 'url',
  tel: 'tel',
  date: 'date',
  time: 'time',
  datetime: 'datetime-local',
}
</script>

<template>
  <label>
    {{ field.label }}
    <input
      v-bind="inputProps"
      :type="inputTypes[field.type] ?? 'text'"
      :value="input ?? ''"
      :placeholder="field.placeholder"
      :required="required"
      @input="setInput(($event.target as HTMLInputElement).value)"
    />
    <small v-if="errors">{{ errors[0] }}</small>
  </label>
</template>
tsx
import type { ValueFieldProps } from '@whatsaaaa/form-builder-react'

// The native input for each type. The rest are text.
const inputTypes: Record<string, string> = {
  email: 'email',
  password: 'password',
  number: 'number',
  url: 'url',
  tel: 'tel',
  date: 'date',
  time: 'time',
  datetime: 'datetime-local',
}

export function TextInput({
  field,
  input,
  errors,
  required,
  inputProps,
  setInput,
}: ValueFieldProps) {
  return (
    <label>
      {field.label}
      <input
        {...inputProps}
        type={inputTypes[field.type] ?? 'text'}
        value={String(input ?? '')}
        placeholder={field.placeholder}
        required={required}
        onChange={(event) => setInput(event.target.value)}
      />
      {errors && <small>{errors[0]}</small>}
    </label>
  )
}
  • field is the field from the definition, with its label, placeholder, options and ui.
  • path is its Formisch path with real indexes, like ['lines', 0, 'quantity'].
  • input is the field's current input, and errors its messages, or null.
  • inputProps are Formisch's element props: the field's name, a ref, and its focus, change and blur handlers. Bind them to the input element, so Formisch knows when the field is touched and can focus it after a failed submit.
  • required is true when the definition makes the field required, like for an asterisk. The schema does the checking.

Setting the value ​

setInput sets the field's input, after turning what an input element gives into what the schema expects, by the field's kind:

KindInputValue set
number'' or only spacesundefined
numbera numeric string, '5'5
filea FileListits first file, or undefined
array of filea FileLista list of its files
array of numbernumeric stringsnumbers

Anything else is set as it is, and the schema reports it if it doesn't fit. In React, your onChange after the spread of inputProps replaces Formisch's, and calls setInput instead. setInput stays the same function while the field is the same, so it is safe in the dependencies of an effect.

Choices ​

An element's value is always a string. To keep 2 a number and false a boolean, set the option's own value from field.options, and use each option's index as the element's value:

vue
<script setup lang="ts">
import type { ValueFieldProps } from '@whatsaaaa/form-builder-vue'
import { computed } from 'vue'

const props = defineProps<ValueFieldProps>()

type Option = { value: string | number | boolean; label: string }
const options = computed(() => (props.field.options ?? []) as Option[])

// An element's value is a string, so each option's index stands for its value.
const selected = computed(() =>
  options.value.findIndex((option) => option.value === props.input),
)
</script>

<template>
  <label>
    {{ field.label }}
    <select
      v-bind="inputProps"
      :value="selected"
      @change="
        setInput(
          options[Number(($event.target as HTMLSelectElement).value)]
            ?.value,
        )
      "
    >
      <option :value="-1">Choose...</option>
      <option
        v-for="(option, index) in options"
        :key="index"
        :value="index"
      >
        {{ option.label }}
      </option>
    </select>
    <small v-if="errors">{{ errors[0] }}</small>
  </label>
</template>
tsx
import type { ValueFieldProps } from '@whatsaaaa/form-builder-react'

type Option = { value: string | number | boolean; label: string }

export function SelectInput({
  field,
  input,
  errors,
  inputProps,
  setInput,
}: ValueFieldProps) {
  const options = (field.options ?? []) as Option[]
  // An element's value is a string, so each option's index stands for its value.
  const selected = options.findIndex((option) => option.value === input)
  return (
    <label>
      {field.label}
      <select
        {...inputProps}
        value={selected}
        onChange={(event) =>
          setInput(options[Number(event.target.value)]?.value)
        }
      >
        <option value={-1}>Choose...</option>
        {options.map((option, index) => (
          <option key={index} value={index}>
            {option.label}
          </option>
        ))}
      </select>
      {errors && <small>{errors[0]}</small>}
    </label>
  )
}
  • A radio sets its option's value when it is picked, and is checked while input is that value.
  • A multiselect's value is a list of option values. A checkbox for an option adds its value to the list, or takes it out.
  • A select without options, because they load from an API, is a text input. With "valueType": "number", its kind is number, so setInput turns what is typed into a number.

Files ​

Pass the element's files to setInput, and never set a file input's value:

vue
<script setup lang="ts">
import type { ValueFieldProps } from '@whatsaaaa/form-builder-vue'
import { computed } from 'vue'

const props = defineProps<ValueFieldProps>()

const accept = computed(() =>
  typeof props.field.accept === 'string' ? props.field.accept : undefined,
)
</script>

<template>
  <label>
    {{ field.label }}
    <!-- Never set a file input's value. setInput turns the FileList into files. -->
    <input
      v-bind="inputProps"
      type="file"
      :multiple="kind === 'array'"
      :accept="accept"
      @change="setInput(($event.target as HTMLInputElement).files)"
    />
    <small v-if="errors">{{ errors[0] }}</small>
  </label>
</template>
tsx
import type { ValueFieldProps } from '@whatsaaaa/form-builder-react'

export function FileInput({
  field,
  kind,
  errors,
  inputProps,
  setInput,
}: ValueFieldProps) {
  return (
    <label>
      {field.label}
      {/* Never set a file input's value. setInput turns the FileList into files. */}
      <input
        {...inputProps}
        type="file"
        multiple={kind === 'array'}
        accept={field.accept as string | undefined}
        onChange={(event) => setInput(event.target.files)}
      />
      {errors && <small>{errors[0]}</small>}
    </label>
  )
}

Groups ​

A group renders its fields where you put them:

vue
<script setup lang="ts">
import type {
  GroupFieldProps,
  GroupFieldSlots,
} from '@whatsaaaa/form-builder-vue'

defineProps<GroupFieldProps>()
defineSlots<GroupFieldSlots>()
</script>

<template>
  <fieldset>
    <legend>{{ field.label }}</legend>
    <slot />
  </fieldset>
</template>
tsx
import type { GroupFieldProps } from '@whatsaaaa/form-builder-react'

export function GroupFieldset({ field, children }: GroupFieldProps) {
  return (
    <fieldset>
      <legend>{field.label}</legend>
      {children}
    </fieldset>
  )
}

Lists ​

A list renders each item's fields, with buttons to add and remove items:

vue
<script setup lang="ts">
import type {
  ArrayFieldProps,
  ArrayFieldSlots,
} from '@whatsaaaa/form-builder-vue'

defineProps<ArrayFieldProps>()
defineSlots<ArrayFieldSlots>()
</script>

<template>
  <fieldset>
    <legend>{{ field.label }}</legend>
    <div v-for="(item, index) in items" :key="item">
      <slot :index="index" />
      <button type="button" @click="remove(index)">Remove</button>
    </div>
    <button type="button" @click="add()">Add</button>
    <small v-if="errors">{{ errors[0] }}</small>
  </fieldset>
</template>
tsx
import type { ArrayFieldProps } from '@whatsaaaa/form-builder-react'

export function ListRepeater({
  field,
  items,
  errors,
  add,
  remove,
  children,
}: ArrayFieldProps) {
  return (
    <fieldset>
      <legend>{field.label}</legend>
      {items.map((item, index) => (
        <div key={item}>
          {children(index)}
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={add}>
        Add
      </button>
      {errors && <small>{errors[0]}</small>}
    </fieldset>
  )
}
  • items has one id per item. Use it as the key, so each item keeps its inputs when one before it is removed or moved.
  • add() adds an item at the end, with the defaults its fields have in the definition. remove(index) and move(from, to) work as in Formisch.
  • errors are the list's own messages, like a required list's or maxLength's. Its items' fields show their own.

Display elements ​

A display element only gets its field, with its text and other props:

vue
<script setup lang="ts">
import type { DisplayFieldProps } from '@whatsaaaa/form-builder-vue'

defineProps<DisplayFieldProps>()
</script>

<template>
  <h2>{{ field.text }}</h2>
</template>
tsx
import type { DisplayFieldProps } from '@whatsaaaa/form-builder-react'

export function FormHeading({ field }: DisplayFieldProps) {
  return <h2>{String(field.text ?? '')}</h2>
}

In Vue, declare the props even when a component doesn't use them, like a divider's, or Vue adds field to its element as an attribute.

Next steps ​

Continue to layout components to render the sections, rows and slots of a layout.