Custom Fields
A custom field component replaces the whole field. Map it by type in components:
<FormRenderer :definition="definition" :components="{ 'x-choice': ChoiceButtons }" @submit="onSubmit" /><FormRenderer definition={definition} components={{ 'x-choice': ChoiceButtons }} onSubmit={handleSubmit} />The same map overrides a built-in type, such as { date: MyDatePicker }. See Custom field types for naming and props.
Field types go in components. Sections, groups, the error summary, the progress, the buttons and every other part around the fields go in layout. components.section, components.group, components.groupItem and components.confirmDialog still work, and warn once in development with the code legacy-layout.
To plug in a whole design system, start with Design systems: mapControls maps one control per type and one shell for the label, helper and error.
Before you write a component, read Value types: each built-in type expects one value shape, and a scale reads its points from validation, not from options. See Scale.
Field helpers
fieldProps(props) in React and useFieldProps(props, emit) in Vue return everything a field needs from the contract: ids, ARIA, flags, the value and the handlers. A text field is then a few lines:
<script setup lang="ts">
import { fieldComponentEmits, fieldComponentProps, useFieldProps } from '@formhaus/vue';
const props = defineProps(fieldComponentProps);
const emit = defineEmits(fieldComponentEmits);
const f = useFieldProps(props, emit);
</script>
<template>
<div>
<label :for="f.id">{{ f.label }}</label>
<input v-bind="f.attrs" :type="f.inputType" :value="f.text" @input="f.onInput" />
<p v-if="f.helperText" :id="f.helperId">{{ f.helperText }}</p>
<p v-if="f.error" :id="f.errorId">{{ f.error }}</p>
</div>
</template>import { fieldProps, type FieldComponentProps } from '@formhaus/react';
export function TextInput(props: FieldComponentProps) {
const f = fieldProps(props);
return (
<div>
<label htmlFor={f.id}>{f.label}</label>
<input {...f.attrs} type={f.inputType} value={f.text} onChange={f.onInput} />
{f.helperText && <p id={f.helperId}>{f.helperText}</p>}
{f.error && <p id={f.errorId}>{f.error}</p>}
</div>
);
}
export function ChoiceButtons(props: FieldComponentProps) {
const f = fieldProps(props);
return (
<fieldset id={f.id} data-fh="field" {...f.aria}>
<legend>{f.label}</legend>
{f.options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={f.value === option.value}
disabled={f.disabled}
onClick={() => f.commit(option.value)}
>
{option.label}
</button>
))}
{f.error && <p id={f.errorId}>{f.error}</p>}
</fieldset>
);
}| Key | Meaning |
|---|---|
id, name | The element id, which is the id prop or a default from the key, and the field key |
label, helperText, error, placeholder | The texts to show. placeholder falls back to mask |
helperId, errorId | `${id}-helper` and `${id}-error` |
describedBy | The helper id and then the error id, only the ones shown |
invalid, required, disabled | error is set, validation.required is set, disabled or loading is set |
minLength, maxLength | validation.minLength and validation.maxLength of a text, email, phone, password or textarea field, for a character counter. The input does not get them as attributes, so typing past the limit shows the error instead of cutting the text |
inputType, inputMode | The type of a native input for the field type (text, email, tel, number, password, date, time or datetime-local), and field.inputMode |
aria | aria-invalid, aria-required and aria-describedby, for a control or group whose helper and error you render |
testId | The test id of the field |
value, text | The value, and the value as a string with '' for null |
change(value) | Saves a value. For number, decimal text becomes a number, empty text becomes null, other text stays a string |
onInput(event) | change with the value of an input event |
commit(value) | A discrete pick: commits with autoAdvance, else saves |
blur(), focus() | Report blur and focus |
options | matrix.columns in a matrix row, else field.options |
option(option, index) | id, name, value, checked, disabled, data-testid and the blur and focus handlers of one radio or checkbox. The first option gets id, the others `${id}-${index}` |
attrs | For a native control whose helper and error you render: id, name, disabled, placeholder, inputMode, autoComplete (Vue: autocomplete), data-testid, aria and the blur and focus handlers |
input | For a component that renders its own label, helper and error: attrs without aria-describedby and data-testid, plus the value. Keep aria-invalid in the DOM: focus after a failed Continue or Submit looks for it. React: value and onChange(event). Vue: modelValue and onUpdate:modelValue, for v-model |
In Vue, f is a reactive object: f.change works in the script and in the template. Do not destructure it.
Map the keys to the props of your design system. A component library renders the helper and error itself and links them with its own ids, so pass it input, which leaves out aria-describedby. MUI TextField puts unknown props on its root element, so the test id goes to the inner input through slotProps.htmlInput:
<TextField
{...f.input}
type={f.inputType}
label={f.label}
error={f.invalid}
helperText={f.error ?? f.helperText}
slotProps={{ htmlInput: { 'data-testid': f.testId } }}
/>With Vuetify: <v-text-field v-bind="f.input" :type="f.inputType" :label="f.label" :error-messages="f.error" :hint="f.helperText" persistent-hint />.
@formhaus/core/fields has the helpers both wrappers use, for any framework:
| Helper | Returns |
|---|---|
fieldState({ id, field, error, disabled, loading }) | The ids, texts, flags and aria above |
fieldId({ id, field }), fieldAria({ id, field, error }) | Only the id, only the ARIA attributes |
fieldValue(field, value), numberValue(text) | The value core accepts for the field. numberValue follows the decimal rule of core: 0x10 stays text |
inputType(field), optionChecked(value, option) | The native input type, and whether an option is picked in a single or list value |
fromDate(date, type), toDate(value) | A date, datetime or time value from a Date, and a Date from such a value. See Value types |
scaleView(field) | The points, min, max and the start, middle and end anchors of a scale or rating. See Scale |
optionText(options, value), optionValue(options, text) | The text an autocomplete shows for a value, and the value for a typed text |
toggleOption(value, option) | A multiselect value with the option added or removed |
Contract
The helpers above follow these rules. A component without them follows the rules itself.
- The component renders its own label, helper text and error message. The renderer adds no wrapper around it.
- Put the
idprop on the focusable control. The error summary links to it, and focus after a failed Continue or Submit looks it up. See Ids and ARIA and Field focus. - Give the error message the
idplus-erroras its id, and setaria-invalidon the control whileerroris set. - Emit every change:
onChange(value)in React,update:modelValuein Vue. - When
field.autoAdvanceis set, a discrete pick emitscommitinstead of a change. See autoAdvance. - Call
onBlurandonFocus(React) or emitblurandfocus(Vue). They reportfield_focusedandfield_blurredform events. - Disable the control while
disabledorloadingis set.
| Prop | React | Vue |
|---|---|---|
| Element id | id | id |
| Field from the definition | field | field |
| Current value | value | modelValue |
| Error message | error | error |
Loading state from FormEngine.setFieldLoading() or a running optionsFrom provider | loading | loading |
| Form is busy or disabled | disabled | disabled |
| Test ids | testId, optionTestId | testId, optionTestId |
| Change | onChange(value) | update:modelValue |
Discrete pick with autoAdvance | onCommit(value), optional | commit |
| Blur and focus | onBlur(), onFocus() | blur, focus |
| Matrix row context | matrix | matrix |
Revealed fields of the options, built-in and mapControls fields only | reveals | reveals |
autoAdvance
A field with "autoAdvance": true goes to the next step when the user picks an answer. A custom choice, scale or rating must then send the pick with f.commit(value). Without the helpers, call onCommit(value) in React or emit('commit', value) in Vue. onChange and update:modelValue only save the value, so with them the step never advances. commit saves the value too: do not also emit a change for that pick. Typing, arrow keys and other edits still emit a change.
<script setup lang="ts">
import { fieldComponentEmits, fieldComponentProps, useFieldProps } from '@formhaus/vue';
const props = defineProps(fieldComponentProps);
const emit = defineEmits(fieldComponentEmits);
const f = useFieldProps(props, emit);
</script>
<template>
<fieldset :id="f.id" data-fh="field" v-bind="f.aria">
<legend>{{ f.label }}</legend>
<button
v-for="option in f.options"
:key="option.value"
type="button"
:aria-pressed="f.value === option.value"
:disabled="f.disabled"
@click="f.commit(option.value)"
>
{{ option.label }}
</button>
<p v-if="f.error" :id="f.errorId">{{ f.error }}</p>
</fieldset>
</template>const pick = (value: string) => {
if (field.autoAdvance && onCommit) onCommit(value)
else onChange(value)
}ChoiceButtons in the React tab of Field helpers is the same field in React. Without the helpers in Vue, keep the two emit calls apart: emit(field.autoAdvance ? 'commit' : 'update:modelValue', value) fails vue-tsc, because each event has its own signature.
Ids and ARIA
The renderer always passes id. It is optional in the type only so that you can render the component on its own. fieldId then falls back to an id from the key. The built-in fields use these ids:
| Element | Id or attribute |
|---|---|
| Focusable control, or the group of a choice field | id |
| Helper text | `${id}-helper` |
| Error message | `${id}-error` |
| Control | aria-describedby with the helper id and then the error id, only the ones shown |
| Control | aria-invalid="true" while error is set |
| Control | aria-required="true" when field.validation.required is set |
| Root of a choice field | a <fieldset> with a <legend>, or role="group" with aria-labelledby |
Field focus
Focus finds the controls of a field by the id and by data-fh attributes:
- The element with the
idgets focus. When it is not aninput,select,textarea,buttonor an element withtabindex, focus goes to the first of those inside it. Focus skipstabindex="-1"andaria-disabled="true". - Put
data-fh="field"on the root of a field. For a radio, scale or rating, focus then goes to the checked option. Without it, and with theidon the first option, focus always lands on the first option. - When only the error message carries the id (
idplus-error),data-fh="field"on the root lets focus find the control. - A file field whose native input is hidden with
tabindex="-1"must put theidon the visible button, or mark the root withdata-fh="field"so that focus finds the button. - In a custom
group, mark the group root withdata-fh="group", each item withdata-fh-group-itemand the Add button withdata-fh-group-add. See Groups: Custom markup.
Value types
A component for a built-in type writes the value that core checks for that type. A value of another shape fails validation.
| Type | Value |
|---|---|
text, email, phone, password, textarea, hidden | string |
autocomplete | string: the option value when the text matches an option label, else the text as typed. optionText and optionValue map between them |
number | finite number, or null when cleared |
select, radio | string, the option value |
multiselect | string[] of distinct option values. toggleOption adds or removes one |
checkbox, switch | boolean |
date | string YYYY-MM-DD |
datetime | string YYYY-MM-DDTHH:mm. Core also accepts seconds, milliseconds and a Z or +HH:mm offset, such as 2026-01-05T09:07:30.5+02:00 |
time | string HH:mm, 24-hour, or HH:mm:ss |
scale, rating | number, the selected point |
file | one File, or null |
| custom type | string, number or boolean |
A file field holds one file. Use one file field per file.
Write null or '' when the user clears the control. An empty value is checked only against required and matchField.
For number, change and the built-in field write null when the input is empty. With change, text that is not a number stays a string, and validation fails with the number message rather than required. A browser number input reports an empty value for such text, so it becomes null. Never write NaN.
A composite control, such as a date picker and a time picker side by side for one datetime, writes one value. Write null until both parts are set: the field then fails only required. A partial string, such as 2026-01-05T, fails the format check and shows the datetime message. Pick one of the two and keep it.
Date pickers usually work with Date objects. fromDate(date, field.type) writes the value in local time, as YYYY-MM-DD, YYYY-MM-DDTHH:mm or HH:mm. toDate(value) reads every format core accepts:
import { fromDate, toDate } from '@formhaus/core/fields'
fromDate(new Date(2026, 0, 5, 9, 7), 'datetime') // '2026-01-05T09:07'
toDate('2026-01-05T09:07') // 5 January 2026, 09:07 local time
toDate('2026-01-05T09:07Z') // the same instant in every time zone
toDate('09:07') // 1 January 1970, 09:07 local time
toDate('2026-02-31') // null- A value without
Zor an offset is local time. A value with one is that instant. - An impossible date, such as
2026-02-31, givesnull. Core accepts such a date as a format, so check it in the component when it matters. - A local time that does not exist, such as 02:30 on the night clocks move forward, moves forward like
new Date():toDate('2026-03-08T02:30')in New York is 03:30.
Scale
A component for scale or rating reads its range and anchor labels from the field:
- The points run from
validation.mintovalidation.max. They default to 1 and 5. A range has at most 11 points.ratingalways starts at 1. - The value is the point as a number, such as
7. A string such as"7"fails validation. optionsare not the points. They are optional anchor labels for some points, and an optionvalueis the point as a string, such as"0"or"10". The built-in scale shows the labels of the first point, the middle pointfloor((min + max) / 2)and the last point.
scaleView(field) from @formhaus/core/fields applies these rules:
import { scaleView } from '@formhaus/core/fields'
const { points, min, max, anchors } = scaleView(field)
// points: [0, 1, ..., 10]
// anchors: [{ point: 0, label: 'Not likely' }, undefined, { point: 10, label: 'Very likely' }]Pick a point with f.commit(point), as a number, so that autoAdvance works. See Scale and Rating.
Layout slots
layout replaces the parts of the form around the fields. A key you leave out keeps the built-in markup.
<FormRenderer :definition="definition" :components="fields" :layout="{ section: MySection, errorSummary: MySummary }" @submit="onSubmit" /><FormRenderer definition={definition} components={fields} layout={{ section: MySection, errorSummary: MySummary }} onSubmit={handleSubmit} />| Key | Renders | React props | Vue props |
|---|---|---|---|
fields | The container around the fields | FieldsContainerProps | fieldsContainerProps |
section | A section | SectionComponentProps | sectionComponentProps |
group | A repeatable group | GroupComponentProps | groupComponentProps |
groupItem | One item of a group | GroupItemComponentProps | groupItemComponentProps |
confirmDialog | The dialog before an item with answers is removed | ConfirmDialogComponentProps | confirmDialogComponentProps |
errorSummary | The error summary | ErrorSummaryComponentProps | errorSummaryComponentProps |
formErrors | Errors that belong to no field | FormErrorsComponentProps | formErrorsComponentProps |
progress | The step progress | FormStepProgressProps | formStepProgressProps |
actions | The buttons | FormActionsProps | formActionsProps |
LayoutComponentMap types the whole map. Every slot gets the values the field components need from Value types: the slots only place the fields, they never change the values.
The <form class="fh-form"> element stays. Add a design-system class to it with className in React. In Vue, class and other attributes on FormRenderer or HeadlessFormRenderer fall through to the <form>.
<FormRenderer class="usa-form" :definition="definition" @submit="onSubmit" /><FormRenderer className="usa-form" definition={definition} onSubmit={handleSubmit} />components.section, components.group, components.groupItem, components.confirmDialog, actionsComponent and progressComponent keep working until 2.0. layout wins when both are set. In development the renderer warns once per old key with the code legacy-layout.
Slot props
| Slot | Props | rootProps |
|---|---|---|
fields | rootProps. The fields come as children in React and as the default slot in Vue | yes |
section | id, section, title, description, descriptionId, columns. The fields come as children in React and as the default slot in Vue | no |
group | The group texts, items, canAdd, add, remove(id), requestRemove(id), addLabel, removeLabel, status, disabled, itemComponent | no |
groupItem | id, index, title, titleId, canRemove, remove, requestRemove, hasContent, removeLabel, removeName, disabled. The fields come as children in React and as the fields component in Vue | no |
confirmDialog | id, open, title, description, confirmLabel, cancelLabel, confirm(), cancel() | no |
errorSummary | rootProps, title, groups, count, showField(key) | yes |
formErrors | rootProps, errors | yes |
progress | current, total, stepTitle, stepDescription, headingLevel, counterLabel, stepErrorCount, stepErrorsLabel, steps, marks, rootProps | yes |
actions | The props in Custom Actions & Progress, plus primaryDisabled and rootProps | yes |
fields: without it the fields sit in a plain<div>with no spacing. A headless integration usually passeslayout.fieldswith the stack or grid of its design system. The progress comes before the fields, and the error summary after them unlesserrorSummaryis"top".section: each child stays wrapped in the renderer component that keeps its value and errors current, so count fields withsection.fields, not with the children. In a matrix section,columnsholds the column options, and each row getsmatrix. In any other sectioncolumnsisundefined. Acomponents.sectiongets nocolumns. See Matrix rows.group: render each item withitemComponent.groupItem: in Vue, render the fields with<component :is="fields" />.confirmDialog: opens only for an item with answers. See Remove dialog.errorSummary:titleis theerrorSummarylabel. Change it withlabels: { errorSummary: 'There is a problem' }on the renderer.groupshas one{ id, title, entries }per step with errors, and each entry is{ key, id, text, message }with the DOM id of the field.textisLabel: messageandmessageis the field message alone, the same text as the error under the field.showFieldopens the step of the field and focuses it. The summary renders when theerrorSummaryprop is notfalseandcountis above 0. WitherrorSummary="top"it renders first in the form, before the progress and the fields. After a failed Continue or Submit, the summary takes focus only when it lists two or more errors. With one error, focus goes to the invalid field. See Error summary and step marks.formErrors:errorsholds theformErrorsof a{ ok: false }submit result and the message of a failed action. It renders whenerrorsis not empty.progress: renders only for a multi-step form.stepsalways holds every step of the active path as{ id, title, errorCount, errorsLabel }.marksistrueonly when the renderer hasstepErrorMarks, so draw the step list whenmarksistrue.stepErrorsLabeland eacherrorsLabelare thestepErrorslabel, such asErrors: 2, and areundefinedwhen the step has no errors.actions:primaryDisabledistruewhile thedisabledconditions of the primary action match, thenextof the step or the formsubmiton the last step. Disable the primary button whileprimaryDisabledorloadingis set. See Custom progress component.
Root props
rootProps carries the attributes that focus and screen readers depend on. Spread it on the root element: {...rootProps} in React, v-bind="rootProps" in Vue. A Vue component must declare rootProps, for example with the exported props object, or the object falls through as an attribute and focus does not find the root.
| Slot | rootProps |
|---|---|
fields | { 'data-fh': 'fields' } |
errorSummary | { 'data-fh': 'error-summary', role: 'alert', tabIndex: -1 } |
formErrors | { 'data-fh': 'form-errors', role: 'alert' } |
progress | { 'data-fh': 'progress' } |
actions | { 'data-fh': 'actions' } |
RootProps is the type. When the renderer has testIds, the rootProps of errorSummary also carry its data-testid.
Put rootProps on one element, and keep one role="alert". Some components type their own role prop, such as Carbon notifications, and reject the role of rootProps. Spread rootProps on a wrapper element then. Some design-system alerts put their attributes on an inner element or on a Transition, and some set their own role="alert". Then wrap the component in an element that takes rootProps, and turn off the role of the inner component if it has an option for that. With a pass-through option, such as pt: { root: rootProps } in PrimeVue, the component can take rootProps itself.
An error summary in React:
import type { ErrorSummaryComponentProps } from '@formhaus/react'
export function ErrorSummary({ rootProps, title, groups, showField }: ErrorSummaryComponentProps) {
return (
<div {...rootProps} className="my-summary">
<strong>{title}</strong>
<ul>
{groups.flatMap((group) => group.entries).map((entry) => (
<li key={entry.key}>
<a
href={`#${entry.id}`}
onClick={(event) => {
event.preventDefault()
showField(entry.key)
}}
>
{entry.text}
</a>
</li>
))}
</ul>
</div>
)
}A fields container in Vue:
<script setup lang="ts">
import { fieldsContainerProps } from '@formhaus/vue'
defineProps(fieldsContainerProps)
</script>
<template>
<div v-bind="rootProps" class="my-stack">
<slot />
</div>
</template>Focus
Focus finds its targets by ids and data-fh attributes. A design system that spreads rootProps and puts the id prop on its controls gets the built-in focus:
- After a failed Continue or Submit with one error, focus goes to the first invalid control inside
[data-fh="fields"]. With several errors, it goes to[data-fh="error-summary"]. - Without
[data-fh="fields"], focus searches the whole form except theprogress,actionsanderrorSummaryroots. showField(key)focuses the field as in Field focus.- Focus after Add and Remove depends on the ids and attributes in Groups: Custom markup.
The fh-form__fields, fh-field, fh-group and fh-error-summary classes work in place of the attributes until 2.0.
Remove dialog
The dialog opens only when the item has answers. Remove on an empty item, or on one that holds only its defaults, removes it at once. confirmRemove={false} in React or :confirm-remove="false" in Vue turns the dialog off.
A layout.confirmDialog stays mounted with its group, so it can animate. Show it while open is true. While it is closed, title and confirmLabel hold the remove label, cancelLabel the cancel label, description is '', and confirm and cancel do nothing. A components.confirmDialog mounts only while a removal waits for an answer, so open is always true there.
import type { ConfirmDialogComponentProps } from '@formhaus/react'
export function RemoveDialog({ id, open, title, description, confirmLabel, cancelLabel, confirm, cancel }: ConfirmDialogComponentProps) {
return (
<div id={id} role="alertdialog" aria-modal="true" aria-labelledby={`${id}-title`} aria-describedby={`${id}-description`} hidden={!open}>
<h2 id={`${id}-title`}>{title}</h2>
<p id={`${id}-description`}>{description}</p>
<button type="button" onClick={cancel}>{cancelLabel}</button>
<button type="button" onClick={confirm}>{confirmLabel}</button>
</div>
)
}A group item in Vue:
<script setup lang="ts">
import { groupItemComponentProps } from '@formhaus/vue'
const props = defineProps(groupItemComponentProps)
</script>
<template>
<article role="group" :aria-labelledby="props.titleId" data-fh-group-item>
<h4 :id="props.titleId" tabindex="-1">{{ props.title }}</h4>
<component :is="props.fields" />
<button
v-if="props.canRemove"
:id="`${props.titleId}-remove`"
type="button"
:aria-label="props.removeName"
:disabled="props.disabled"
@click="props.requestRemove"
>
{{ props.removeLabel }}
</button>
</article>
</template>DefaultSection, DefaultGroup, DefaultGroupItem and DefaultConfirmDialog are exported for wrapping.
Matrix rows
A field in a layout: "matrix" section gets matrix: { columns, row }. columns are the options of the first row, and row is the index of the field among the visible fields of the section. Outside a matrix, matrix is undefined. In Vue, the field gets matrix only when it declares the prop, as fieldComponentProps does.
A custom section replaces the built-in matrix layout, so it draws the column headers from its columns prop, and each row field draws its own cells from matrix.columns. The value of a row is the option value, as for any radio or multiselect.
import { fieldProps, type FieldComponentProps, type SectionComponentProps } from '@formhaus/react';
export function MatrixSection({ id, title, description, descriptionId, columns, children }: SectionComponentProps) {
return (
<fieldset id={id} aria-describedby={descriptionId} className={columns ? 'matrix' : undefined}>
<legend>{title}</legend>
{description && <p id={descriptionId}>{description}</p>}
{columns && (
<div className="matrix__header" aria-hidden="true">
{columns.map((column) => (
<span key={column.value}>{column.label}</span>
))}
</div>
)}
{children}
</fieldset>
);
}
export function RadioRow(props: FieldComponentProps) {
const f = fieldProps(props);
const { matrix } = props;
return (
<fieldset data-fh="field" data-row={matrix?.row} className={matrix ? 'matrix__row' : undefined} {...f.aria}>
<legend>{f.label}</legend>
{f.options.map((option, index) => (
<label key={option.value}>
<input type="radio" {...f.option(option, index)} onChange={() => f.change(option.value)} />
<span className={matrix ? 'visually-hidden' : undefined}>{option.label}</span>
</label>
))}
{f.error && <p id={f.errorId}>{f.error}</p>}
</fieldset>
);
}<script setup lang="ts">
import { sectionComponentProps } from '@formhaus/vue';
const props = defineProps(sectionComponentProps);
</script>
<template>
<fieldset :id="props.id" :aria-describedby="props.descriptionId" :class="{ matrix: props.columns }">
<legend>{{ props.title }}</legend>
<p v-if="props.description" :id="props.descriptionId">{{ props.description }}</p>
<div v-if="props.columns" class="matrix__header" aria-hidden="true">
<span v-for="column in props.columns" :key="column.value">{{ column.label }}</span>
</div>
<slot />
</fieldset>
</template><script setup lang="ts">
import { fieldComponentEmits, fieldComponentProps, useFieldProps } from '@formhaus/vue';
const props = defineProps(fieldComponentProps);
const emit = defineEmits(fieldComponentEmits);
const f = useFieldProps(props, emit);
</script>
<template>
<fieldset data-fh="field" :data-row="props.matrix?.row" :class="{ matrix__row: props.matrix }" v-bind="f.aria">
<legend>{{ f.label }}</legend>
<label v-for="(option, index) in f.options" :key="option.value">
<input type="radio" v-bind="f.option(option, index)" @change="f.change(option.value)" />
<span :class="{ 'visually-hidden': props.matrix }">{{ option.label }}</span>
</label>
<p v-if="f.error" :id="f.errorId">{{ f.error }}</p>
</fieldset>
</template>Map the row as the field type and the section as a slot:
<FormRenderer :definition="definition" :components="{ radio: RadioRow }" :layout="{ section: MatrixSection }" @submit="onSubmit" /><FormRenderer definition={definition} components={{ radio: RadioRow }} layout={{ section: MatrixSection }} onSubmit={handleSubmit} />Fully custom layout
Build the whole form on useFormEngine and read the error summary with errorSummary from @formhaus/core.
import { errorSummary, type FormDefinition, type SubmitHandler } from '@formhaus/core';
import { useFormEngine } from '@formhaus/react';
type Props = { definition: FormDefinition; send: SubmitHandler };
export function CustomLayoutForm({ definition, send }: Props) {
const engine = useFormEngine(definition);
const { groups, topLevelErrors, count } = errorSummary(engine, { idPrefix: 'signup' });
const showField = (key: string, id: string) => {
engine.goToStepWithField(key);
setTimeout(() => document.getElementById(id)?.focus());
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
engine.submit(send);
}}
>
{engine.visibleFields.map((field) => (
<label key={field.key}>
{field.label}
<input
id={`signup-${field.key}`}
aria-invalid={!!engine.errors[field.key]}
value={String(engine.values[field.key] ?? '')}
onChange={(event) => engine.setValue(field.key, event.target.value)}
/>
</label>
))}
{topLevelErrors.map((error) => (
<p key={error} role="alert">
{error}
</p>
))}
{count > 0 && (
<nav aria-label={`${count} errors`}>
{groups.map((group) => (
<ul key={group.id} aria-label={group.title}>
{group.entries.map((entry) => (
<li key={entry.key}>
<a
href={`#${entry.id}`}
onClick={(event) => {
event.preventDefault();
showField(entry.key, entry.id);
}}
>
{entry.text}
</a>
</li>
))}
</ul>
))}
</nav>
)}
{engine.isLastStep ? (
<button type="submit">Send</button>
) : (
<button type="button" onClick={() => engine.next()}>
Next
</button>
)}
</form>
);
}<script setup lang="ts">
import { errorSummary, type FormDefinition, type SubmitHandler } from '@formhaus/core';
import { useFormEngine } from '@formhaus/vue';
import { computed, nextTick } from 'vue';
const props = defineProps<{ definition: FormDefinition; send: SubmitHandler }>();
const { engine, visibleFields, values, errors, topLevelErrors, isLastStep } = useFormEngine(() => props.definition);
const summary = computed(() => {
void [values.value, errors.value, topLevelErrors.value];
return errorSummary(engine, { idPrefix: 'signup' });
});
function input(key: string, event: Event) {
engine.setValue(key, (event.target as HTMLInputElement).value);
}
async function showField(key: string, id: string) {
engine.goToStepWithField(key);
await nextTick();
document.getElementById(id)?.focus();
}
</script>
<template>
<form @submit.prevent="engine.submit(props.send)">
<label v-for="field in visibleFields" :key="field.key">
{{ field.label }}
<input :id="`signup-${field.key}`" :aria-invalid="!!errors[field.key]" :value="values[field.key] ?? ''" @input="input(field.key, $event)" />
</label>
<p v-for="error in summary.topLevelErrors" :key="error" role="alert">{{ error }}</p>
<nav v-if="summary.count > 0" :aria-label="`${summary.count} errors`">
<ul v-for="group in summary.groups" :key="group.id" :aria-label="group.title">
<li v-for="entry in group.entries" :key="entry.key">
<a :href="`#${entry.id}`" @click.prevent="showField(entry.key, entry.id)">{{ entry.text }}</a>
</li>
</ul>
</nav>
<button v-if="isLastStep" type="submit">Send</button>
<button v-else type="button" @click="engine.next()">Next</button>
</form>
</template>errorSummary(engine, { idPrefix?, labels? }) returns { groups, topLevelErrors, count }. It reads the engine errors only: groups and count are the ones the errorSummary slot gets, and topLevelErrors is engine.topLevelErrors, without the message of a failed onSubmit. Each entry id is the DOM id the Formhaus renderers give the control of that field, with idPrefix as the form id (fh without one). For a top-level key made of letters, digits, _ and - that is <idPrefix>-<key>, as in the snippet, except that a run of dashes before reveal becomes one dash; group items and other keys get derived ids. labels overrides the definition labels for step and item titles.
Next steps
- Field Types: built-in fields and their settings
- Custom Actions & Progress: buttons, step progress and runtime props in Vue
- Validation: rules and custom validators