Design systems
mapControls builds a components map from your controls and one shell. Most field types take one line:
import { mapControls } from '@formhaus/react/controls'
const components = mapControls({
shell: Shell,
controls: {
input: Input,
textarea: Textarea,
select: { component: Select, optionsProp: 'options' },
checkbox: Checkbox,
radio: { component: RadioGroup, optionsProp: 'options' },
},
})
<FormRenderer definition={definition} components={components} onSubmit={handleSubmit} />import { mapControls } from '@formhaus/vue/controls'
const components = mapControls({
shell: Shell,
controls: {
input: VTextField,
textarea: VTextarea,
select: { component: VSelect, props: (f, options) => ({ items: options, itemTitle: 'label' }) },
checkbox: VCheckbox,
},
})A control is a component, or an object with the component and how to call it. See Control entries.
The result is a plain components map. Mix it with hand-written fields: { ...mapControls(config), file: FileField }. Types you leave out keep the built-in fields in FormRenderer.
Call mapControls once at module level. Each call creates new components, so a call inside a render function remounts every field and loses focus.
Kinds
Each type has a kind. The kind decides what the shell renders and how the control gets its value.
| Kind | Types | Shell | Value prop |
|---|---|---|---|
input | text, email, phone, number, password, date, time, datetime, textarea, select, autocomplete, file, custom types | <label for> above the control | React value, Vue modelValue |
inline | checkbox, switch | <label for> after the control | React checked, Vue modelValue |
group | radio, multiselect, scale, rating | <fieldset> and <legend> | React value, Vue modelValue |
kinds changes the kind of a type. A multiselect drawn as one select box is an input: kinds: { multiselect: 'input' }.
The input key covers text, email, phone, number, password, date, time and datetime, and only that control gets type from inputType. A key for one type wins over input: password: Input.Password.
Shell
The shell renders the label, the helper and the error around one control. It gets kind, field, f (the object from field helpers), rootProps, anchors and the control as children in React or the default slot in Vue.
import type { ControlShellProps } from '@formhaus/react/controls'
export function Shell({ kind, f, rootProps, children }: ControlShellProps) {
const label = <label htmlFor={f.id}>{f.label}</label>
const texts = (
<>
{f.helperText && <p id={f.helperId}>{f.helperText}</p>}
{f.error && <p id={f.errorId}>{f.error}</p>}
</>
)
if (kind === 'group')
return (
<fieldset {...rootProps}>
<legend>{f.label}</legend>
{children}
{texts}
</fieldset>
)
return (
<div {...rootProps}>
{kind === 'input' && label}
{children}
{kind === 'inline' && label}
{texts}
</div>
)
}- Spread
rootPropson the root:{...rootProps}in React,v-bind="rootProps"in Vue. It carriesdata-fh="field"for focus, the keyboard handlers of autoAdvance, and on a group the test id. A group shell also getsaria-invalidandaria-describedbyfor its<fieldset>. - Give the helper the id
f.helperIdand the error the idf.errorId. The control points at them witharia-describedby. - In Vue, declare the props with
controlShellPropsfrom@formhaus/vue/controls. anchorsholds the labels of ascaleat its first, middle and last point, and is empty for other types.
shell takes one component, one per kind such as { inline: InlineShell, group: GroupShell }, or null. A kind without a shell renders the control in a <div> with rootProps. The control then gets the props from labels(f), for a component that renders its own label, helper and error:
mapControls({
shell: { input: null, group: GroupShell },
labels: (f) => ({ label: f.label, error: f.invalid, helperText: f.error ?? f.helperText }),
controls: { input: TextField },
})Without a shell the control gets no aria-describedby: the component links its own helper.
In development, a control with two <label for> elements logs a double-label warning. Set the shell of that kind to null, or do not pass a label to the control.
Controls
Every control gets these props:
| Prop | Value |
|---|---|
id, name | The field id and key. Put id on the focusable element |
value or checked (React), modelValue (Vue) | The value in the shape of the kind: text as a string, '' when empty, a list for multiselect, a boolean for checkbox and switch |
onChange (React), onUpdate:modelValue (Vue) | Saves the value. See below |
disabled | disabled or loading is set |
placeholder, inputMode, autoComplete (Vue: autocomplete) | From the field, when set, on input controls only |
aria-invalid, aria-required | While error is set, and when validation.required is set |
aria-describedby | With a shell, on input and inline controls |
data-testid | The test id on input and inline controls, on the root of a group |
type | Only for the input key |
rows | From the field, on textarea controls |
accept | From the field, on file controls |
onBlur, onFocus | Report blur and focus |
onChange takes what the component sends and writes the value core checks:
- An event:
event.target.value,event.target.checkedforcheckboxandswitch, the first file ofevent.target.filesforfile. With a second argument, such as MUI(event, value), the second argument. - A bare value, such as Ant Design
Select. - A
File, or a list of files such as VuetifyVFileInputsends: the first file. - An option object
{ value, label }, or a list of them: the option values. - A
Date, or a date library object withtoDate()such as dayjs, fordate,timeanddatetime: the value format of the type. - For
number, decimal text becomes a number and empty textnull. Forscaleandrating, text becomes a number, and aratingof0becomesnull.
Control entries
Options are never passed by default. An object entry says where they go and which props the control gets:
controls: {
input: TextField,
select: { component: Select, optionsProp: 'options' },
autocomplete: { component: Combobox, optionsProp: 'data' },
multiselect: { component: Select, optionsProp: 'options', props: () => ({ mode: 'multiple' }) },
radio: {
component: RadioGroup,
props: (f, options) => ({ children: options.map((option) => <Radio key={option.value} {...option} />) }),
},
checkbox: { component: Checkbox, changeProp: 'onCheckedChange' },
}| Key | Value |
|---|---|
component | The control component |
props | A function that gets f and the options and returns props. Its props override the preset and optionsProp |
optionsProp | The name of the prop that gets the options |
changeProp | The name of the prop that reports changes. React uses onChange by default, Vue onUpdate:modelValue. See Change prop |
optionsare the field options, the matrix columns in a matrix row, or the points of ascaleorratingas{ value: 3, label: '3' }.- In Vue,
childrenfrompropsbecomes the default slot. f.maxLengthandf.minLengthfeed the character counter of a design system, such as Ant DesignshowCount, Element Plusshow-word-limitor the GOV.UK character count:textarea: { component: Input.TextArea, props: (f) => ({ showCount: true, maxLength: f.maxLength }) }. A nativemaxLengthstops typing at the limit; leave it out to let the user type on and see the error.
React types props, optionsProp and changeProp with the props of the component, and Vue with its $props. A wrong prop name, or a wrong type in a returned prop, fails the type check. Literal unions such as size: 'large' are checked as strings.
Inside the mapControls call the parameters of props get their types. When you declare an entry outside the call, type it with ControlSpec from the /controls entry: const select: ControlSpec<typeof Select> = { component: Select, optionsProp: 'options' }. Then optionsProp and changeProp stay checked and props gets typed parameters. A props function declared on its own takes (f: FieldProps, options: ControlOption[]) => ....
Tuple shorthand
A tuple is short for an object entry: [component, props or optionsProp, changeProp]. A string in the second place is optionsProp, a function is props, and null skips it. These entries match the ones above:
controls: {
select: [Select, 'options'],
multiselect: [Select, (f, options) => ({ options, mode: 'multiple' })],
checkbox: [Checkbox, null, 'onCheckedChange'],
}The tuple is type checked the same way as the object.
Change prop
A component that reports changes in another prop names it in changeProp. The value then goes through the same onChange rules and autoAdvance handling. shadcn/ui, on Radix:
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Switch } from '@/components/ui/switch'
const radios = (f: FieldProps, options: ControlOption[]) => ({
value: f.text,
children: options.map((option, index) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem id={`${f.id}-${index}`} value={String(option.value)} />
<Label htmlFor={`${f.id}-${index}`}>{option.label}</Label>
</div>
)),
})
const components = mapControls({
shell: Shell,
controls: {
input: Input,
textarea: Textarea,
checkbox: { component: Checkbox, changeProp: 'onCheckedChange' },
switch: { component: Switch, changeProp: 'onCheckedChange' },
radio: { component: RadioGroup, props: radios, changeProp: 'onValueChange' },
scale: { component: RadioGroup, props: radios, changeProp: 'onValueChange' },
},
})Radix compares string values, so value: f.text passes a scale point as text. In Vue, changeProp names the event listener, such as 'onUpdate:value'.
Free text
An autocomplete that takes typed text, such as Mantine Autocomplete, needs freeText:
mapControls({
shell: Shell,
freeText: true,
controls: {
autocomplete: { component: Autocomplete, props: (_, options) => ({ data: options.map((option) => option.label) }) },
},
})The control gets the typed text as its value. Text that matches an option label saves the option value, other text saves as typed. With two options of the same label, the first one wins. Without freeText the control gets and sends option values, as MUI Autocomplete, Ant Design Select and Vuetify VAutocomplete do.
Conditional reveal
With conditionalReveal on the renderer, list the types whose props draw revealed fields under the options. Each option that props gets then has reveal: { id, open, children }. Put children in a block with that id and data-fh="reveal" under the option, hide the block while open is false, and give the option input aria-controls={option.reveal.id}. Give a checkbox option aria-expanded={option.reveal.open} too.
const radios = (f: FieldProps, options: ControlOption[]) => ({
children: options.map((option) => (
<Fragment key={option.value}>
<FormControlLabel value={option.value} label={option.label} control={<Radio inputProps={{ 'aria-controls': option.reveal?.id }} />} />
{option.reveal && (
<Box id={option.reveal.id} data-fh="reveal" hidden={!option.reveal.open} sx={{ ml: 1.5, pl: 3, borderLeft: 4, borderColor: 'divider' }}>
{option.reveal.children}
</Box>
)}
</Fragment>
)),
})
mapControls({ shell: Shell, conditionalReveal: ['radio'], controls: { radio: { component: RadioGroup, props: radios } } })A type that is not listed keeps the revealed fields after the group, so list a type only when its props draw option.reveal for every option. A multiselect drawn as a dropdown cannot show fields under an option. The revealed fields sit inside the option group of the design system, so keep them out of its group context: a nested checkbox must not join a parent checkbox group, and a <fieldset disabled> around the options disables them too. Keys and clicks from the revealed fields do not trigger the autoAdvance wiring of the parent, as long as the block has data-fh="reveal".
autoAdvance
For radio, scale and rating with autoAdvance, rootProps wire the same keys as the built-in fields: a click, Enter or Space commits the pick and goes to the next step, arrow keys, Home and End only change the value. A held Space commits once on release. Native radios work as they are, and so do components that pick an option on focus or on their own Space or Enter handler, such as Radix: each press commits once. Clicks, Space and Enter commit only on an option: a native radio, an element with role="radio" or role="option", or a button with a value. A clear button or a text input inside the group keeps its own keys and does not commit. See autoAdvance.
MUI
import type { FieldComponentMap, FieldProps } from '@formhaus/react'
import { type ControlOption, mapControls } from '@formhaus/react/controls'
import { Checkbox, FormControlLabel, MenuItem, Radio, RadioGroup, Rating, Switch, TextField } from '@mui/material'
const labels = (f: FieldProps) => ({
label: f.label,
required: f.required,
error: f.invalid,
helperText: f.error ?? f.helperText,
fullWidth: true,
margin: 'normal',
'data-testid': undefined,
slotProps: {
inputLabel: { shrink: f.inputType.includes('date') || f.inputType === 'time' || undefined },
htmlInput: { 'data-testid': f.testId },
},
})
const menu = (options: ControlOption[]) =>
options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))
const radios = (options: ControlOption[]) =>
options.map((option) => <FormControlLabel key={option.value} value={option.value} control={<Radio />} label={option.label} />)
export const components: FieldComponentMap = {
...mapControls({
shell: { input: null, inline: InlineShell, group: GroupShell },
labels,
kinds: { multiselect: 'input' },
matrix: MatrixRow,
controls: {
input: TextField,
textarea: { component: TextField, props: () => ({ multiline: true, minRows: 3 }) },
select: { component: TextField, props: (_, options) => ({ select: true, children: menu(options) }) },
multiselect: {
component: TextField,
props: (_, options) => ({ select: true, slotProps: { select: { multiple: true } }, children: menu(options) }),
},
checkbox: { component: Checkbox, props: (f) => ({ inputProps: f.aria }) },
switch: { component: Switch, props: (f) => ({ inputProps: f.aria }) },
radio: { component: RadioGroup, props: (_, options) => ({ children: radios(options) }) },
scale: { component: RadioGroup, props: (_, options) => ({ row: true, children: radios(options) }) },
rating: { component: Rating, props: (_, options) => ({ max: options.length }) },
},
}),
autocomplete: AutocompleteField,
file: FileField,
}TextField renders its own label, helper and error, so the input kind has no shell. It puts unknown props such as data-testid on its root, so labels moves the test id to the input with slotProps.htmlInput. MUI Checkbox and Switch put unknown props on their root, so inputProps moves the ARIA to the input. The shells use FormControl:
import type { ControlShellProps } from '@formhaus/react/controls'
import { Box, FormControl, FormControlLabel, FormHelperText, FormLabel, Typography } from '@mui/material'
import type { ReactElement } from 'react'
function Texts({ f }: Pick<ControlShellProps, 'f'>) {
return (
<>
{f.helperText && <FormHelperText id={f.helperId}>{f.helperText}</FormHelperText>}
{f.error && <FormHelperText id={f.errorId}>{f.error}</FormHelperText>}
</>
)
}
export function InlineShell({ f, rootProps, children }: ControlShellProps) {
return (
<FormControl {...rootProps} error={f.invalid}>
<FormControlLabel control={children as ReactElement} label={f.label} required={f.required} />
<Texts f={f} />
</FormControl>
)
}
export function GroupShell({ f, rootProps, anchors, children }: ControlShellProps) {
return (
<FormControl {...rootProps} component="fieldset" error={f.invalid} fullWidth>
<FormLabel component="legend" required={f.required}>
{f.label}
</FormLabel>
{children}
{anchors.some(Boolean) && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
{anchors.map((anchor, index) => (
<Typography key={index} variant="caption">
{anchor?.label}
</Typography>
))}
</Box>
)}
<Texts f={f} />
</FormControl>
)
}Ant Design
import { toDate } from '@formhaus/core/fields'
import type { FieldComponentMap, FieldProps } from '@formhaus/react'
import { mapControls } from '@formhaus/react/controls'
import { Checkbox, DatePicker, Input, InputNumber, Radio, Rate, Select, Switch, TimePicker } from 'antd'
import dayjs from 'dayjs'
const picked = (f: FieldProps) => {
const date = toDate(f.value)
return { value: date && dayjs(date), style: { width: '100%' } }
}
export const components: FieldComponentMap = {
...mapControls({
shell: Shell,
kinds: { multiselect: 'input' },
matrix: MatrixRow,
controls: {
input: Input,
password: Input.Password,
number: { component: InputNumber, props: () => ({ style: { width: '100%' } }) },
textarea: { component: Input.TextArea, props: () => ({ rows: 4 }) },
select: { component: Select, optionsProp: 'options' },
multiselect: { component: Select, optionsProp: 'options', props: () => ({ mode: 'multiple' }) },
autocomplete: { component: Select, optionsProp: 'options', props: () => ({ showSearch: { optionFilterProp: 'label' } }) },
date: { component: DatePicker, props: picked },
datetime: {
component: DatePicker,
props: (f) => ({ ...picked(f), showTime: { format: 'HH:mm' }, format: 'YYYY-MM-DD HH:mm' }),
},
time: { component: TimePicker, props: (f) => ({ ...picked(f), format: 'HH:mm' }) },
checkbox: Checkbox,
switch: Switch,
radio: { component: Radio.Group, optionsProp: 'options', props: () => ({ vertical: true }) },
scale: {
component: Radio.Group,
optionsProp: 'options',
props: () => ({ optionType: 'button', buttonStyle: 'solid', block: true }),
},
rating: { component: Rate, props: (_, options) => ({ count: options.length }) },
},
}),
file: FileField,
}The pickers send a dayjs object, which onChange writes as the value of the type. The shell wraps Form.Item; a group gets a <fieldset>:
import type { ControlShellProps } from '@formhaus/react/controls'
import { Form, Typography } from 'antd'
export function Shell({ kind, f, rootProps, anchors, children }: ControlShellProps) {
const help = (f.helperText || f.error) && (
<>
{f.helperText && <div id={f.helperId}>{f.helperText}</div>}
{f.error && <div id={f.errorId}>{f.error}</div>}
</>
)
const item = (
<Form.Item
label={kind === 'input' ? f.label : undefined}
htmlFor={f.id}
required={f.required}
validateStatus={f.invalid ? 'error' : undefined}
help={help}
>
{kind === 'inline' ? (
<span className="inline-label">
{children}
<label htmlFor={f.id}>{f.label}</label>
</span>
) : (
children
)}
{anchors.some(Boolean) && (
<div className="scale-anchors">
{anchors.map((anchor, index) => (
<Typography.Text key={index} type="secondary">
{anchor?.label}
</Typography.Text>
))}
</div>
)}
</Form.Item>
)
return kind === 'group' ? (
<fieldset {...rootProps} className="choice-group">
<legend>
{f.label}
{f.required && <span aria-hidden="true"> *</span>}
</legend>
{item}
</fieldset>
) : (
<div {...rootProps}>{item}</div>
)
}Vuetify
import type { FieldComponentMap, FieldProps } from '@formhaus/vue'
import { type ControlOption, mapControls } from '@formhaus/vue/controls'
import { h } from 'vue'
import { VAutocomplete, VCheckbox, VFileInput, VRadio, VRadioGroup, VRating, VSelect, VSwitch, VTextarea, VTextField } from 'vuetify/components'
const labels = (f: FieldProps) => ({ label: f.label, errorMessages: f.error, hint: f.helperText, persistentHint: !!f.helperText })
const items = (_: FieldProps, options: ControlOption[]) => ({ items: options, itemTitle: 'label', itemValue: 'value' })
const radios = (options: ControlOption[]) => () => options.map((option) => h(VRadio, { label: option.label, value: option.value }))
export const components: FieldComponentMap = mapControls({
shell: { group: GroupShell },
labels,
kinds: { multiselect: 'input' },
matrix: MatrixRow,
controls: {
input: VTextField,
textarea: VTextarea,
select: { component: VSelect, props: items },
multiselect: { component: VSelect, props: (f, options) => ({ ...items(f, options), multiple: true }) },
autocomplete: { component: VAutocomplete, props: items },
checkbox: VCheckbox,
switch: { component: VSwitch, props: () => ({ color: 'primary' }) },
file: VFileInput,
radio: { component: VRadioGroup, props: (_, options) => ({ hideDetails: true, children: radios(options) }) },
scale: { component: VRadioGroup, props: (_, options) => ({ inline: true, hideDetails: true, children: radios(options) }) },
rating: { component: VRating, props: (_, options) => ({ length: options.length, hover: true }) },
},
})Vuetify inputs render their own label and messages, so only groups have a shell:
<script setup lang="ts">
import { controlShellProps } from '@formhaus/vue/controls'
const props = defineProps(controlShellProps)
</script>
<template>
<fieldset v-bind="props.rootProps" class="group">
<legend class="text-body-1">
{{ props.f.label }}<span v-if="props.f.required" aria-hidden="true"> *</span>
</legend>
<slot />
<div v-if="props.anchors.some(Boolean)" class="group__anchors text-caption">
<span v-for="(anchor, index) in props.anchors" :key="index">{{ anchor?.label }}</span>
</div>
<div v-if="props.f.helperText" :id="props.f.helperId" class="text-caption">{{ props.f.helperText }}</div>
<div v-if="props.f.error" :id="props.f.errorId" class="text-caption text-error">{{ props.f.error }}</div>
</fieldset>
</template>What stays custom
- Matrix rows. Pass their component as
matrix: rows in a matrix section render it instead of the mapped control. Draw the column headers in alayout.section. - Fields with more than one control, such as a file picker with a button and a list, or MUI
AutocompletewithrenderInput. Write them with the field helpers and add them next to the map. hidden: the built-in field renders no markup.