Skip to content
Open Cloud

Design systems ​

mapControls builds a components map from your controls and one shell. Most field types take one line:

tsx
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} />
ts
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.

KindTypesShellValue prop
inputtext, email, phone, number, password, date, time, datetime, textarea, select, autocomplete, file, custom types<label for> above the controlReact value, Vue modelValue
inlinecheckbox, switch<label for> after the controlReact checked, Vue modelValue
groupradio, 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.

tsx
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 rootProps on the root: {...rootProps} in React, v-bind="rootProps" in Vue. It carries data-fh="field" for focus, the keyboard handlers of autoAdvance, and on a group the test id. A group shell also gets aria-invalid and aria-describedby for its <fieldset>.
  • Give the helper the id f.helperId and the error the id f.errorId. The control points at them with aria-describedby.
  • In Vue, declare the props with controlShellProps from @formhaus/vue/controls.
  • anchors holds the labels of a scale at 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:

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

PropValue
id, nameThe 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
disableddisabled or loading is set
placeholder, inputMode, autoComplete (Vue: autocomplete)From the field, when set, on input controls only
aria-invalid, aria-requiredWhile error is set, and when validation.required is set
aria-describedbyWith a shell, on input and inline controls
data-testidThe test id on input and inline controls, on the root of a group
typeOnly for the input key
rowsFrom the field, on textarea controls
acceptFrom the field, on file controls
onBlur, onFocusReport blur and focus

onChange takes what the component sends and writes the value core checks:

  • An event: event.target.value, event.target.checked for checkbox and switch, the first file of event.target.files for file. 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 Vuetify VFileInput sends: the first file.
  • An option object { value, label }, or a list of them: the option values.
  • A Date, or a date library object with toDate() such as dayjs, for date, time and datetime: the value format of the type.
  • For number, decimal text becomes a number and empty text null. For scale and rating, text becomes a number, and a rating of 0 becomes null.

Control entries ​

Options are never passed by default. An object entry says where they go and which props the control gets:

tsx
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' },
}
KeyValue
componentThe control component
propsA function that gets f and the options and returns props. Its props override the preset and optionsProp
optionsPropThe name of the prop that gets the options
changePropThe name of the prop that reports changes. React uses onChange by default, Vue onUpdate:modelValue. See Change prop
  • options are the field options, the matrix columns in a matrix row, or the points of a scale or rating as { value: 3, label: '3' }.
  • In Vue, children from props becomes the default slot.
  • f.maxLength and f.minLength feed the character counter of a design system, such as Ant Design showCount, Element Plus show-word-limit or the GOV.UK character count: textarea: { component: Input.TextArea, props: (f) => ({ showCount: true, maxLength: f.maxLength }) }. A native maxLength stops 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:

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

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

tsx
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.

tsx
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 ​

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

tsx
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 ​

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

tsx
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 ​

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

vue
<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 a layout.section.
  • Fields with more than one control, such as a file picker with a button and a list, or MUI Autocomplete with renderInput. Write them with the field helpers and add them next to the map.
  • hidden: the built-in field renders no markup.