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:
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
}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:
| Role | Which fields | Props |
|---|---|---|
value | every field with a value | field, path, kind, required, input, errors, inputProps, setInput |
group | a group, or a container of kind object | field, path, and the group's fields |
array | a list, or a container of kind array | field, path, items, errors, add, remove, move, and each item's fields |
display | a heading, a paragraph, a divider | field |
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:
<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>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>
)
}fieldis the field from the definition, with itslabel,placeholder,optionsandui.pathis its Formisch path with real indexes, like['lines', 0, 'quantity'].inputis the field's current input, anderrorsits messages, ornull.inputPropsare 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.requiredis 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:
| Kind | Input | Value set |
|---|---|---|
number | '' or only spaces | undefined |
number | a numeric string, '5' | 5 |
file | a FileList | its first file, or undefined |
array of file | a FileList | a list of its files |
array of number | numeric strings | numbers |
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:
<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>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
inputis 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
selectwithoutoptions, because they load from an API, is a text input. With"valueType": "number", its kind isnumber, sosetInputturns what is typed into a number.
Files
Pass the element's files to setInput, and never set a file input's value:
<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>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:
<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>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:
<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>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>
)
}itemshas 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)andmove(from, to)work as in Formisch.errorsare the list's own messages, like a required list's ormaxLength's. Its items' fields show their own.
Display elements
A display element only gets its field, with its text and other props:
<script setup lang="ts">
import type { DisplayFieldProps } from '@whatsaaaa/form-builder-vue'
defineProps<DisplayFieldProps>()
</script>
<template>
<h2>{{ field.text }}</h2>
</template>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.