Repeatable groups
A group repeats a set of fields, and people add or remove items. Requires @formhaus/core 0.14 and "formatVersion": "1.1".
{
"formatVersion": "1.1",
"id": "registration",
"title": "Registration",
"submit": { "label": "Register" },
"fields": [
{
"key": "participants",
"type": "group",
"label": "Participants",
"itemLabel": "Participant {index}",
"addLabel": "Add participant",
"removeLabel": "Remove",
"defaultItems": 1,
"validation": { "required": true, "maxLength": 5 },
"fields": [
{ "key": "name", "type": "text", "label": "Name", "validation": { "required": true } },
{ "key": "email", "type": "email", "label": "Email", "validation": { "required": true } },
{ "key": "diet", "type": "select", "label": "Diet", "options": [{ "value": "none", "label": "None" }, { "value": "other", "label": "Other" }] },
{ "key": "dietNote", "type": "text", "label": "Details", "show": [{ "field": "diet", "eq": "other" }] }
]
}
]
}The submitted value is an array of objects:
{ "participants": [{ "name": "Ada", "email": "ada@example.com", "diet": "none" }] }A hidden item field is left out of its item. Text values are trimmed per item, and unknown item keys are never submitted.
Properties
| Property | Type | Description |
|---|---|---|
key | string | Key of the item array. |
type | "group" | |
label | string | Group heading. |
helperText | string | Text under the heading. |
itemLabel | string | Item heading. {index} is the item number from 1, {label} the group label. Default {label} {index}. |
addLabel, removeLabel | string | Button texts. Default Add and Remove. |
defaultItems | integer | Number of items the group starts with. |
show, showAny | condition[] | Group visibility. A hidden group is cleared, and when it shows again it starts with defaultItems new items. |
validation | object | required, minLength, maxLength, their messages and validator. |
fields | field[] | Fields of one item. |
Item count
required: truemeans at least one item.minLengthandmaxLengthcount items. Their default messages areMust be at least {min} itemsandMust be at most {max} items.- Renderers hide Add at
maxLength, or at 100 items without it. They hide Remove atminLength, or at 1 item whenrequired. validatorreceives the whole array, so it can check rules across items.
Conditions inside items
- An item field condition can test the other fields of the same item. It runs per item.
- It can also test fields outside the group, under the usual step rules.
- A field outside the group cannot test an item field. That is a
group-scopeerror. Test the group itself instead, such as{ "field": "participants", "notEmpty": true }. matchFieldfollows the same scope, so"matchField": "email"inside an item compares with the email of that item.
Item keys must be unique in the group and must not repeat a key outside it. No other key may start with participants.. Groups do not nest, and file and hidden fields are not allowed in a group.
Error paths
Item errors use <group>.<index>.<field> with a 0-based index. Count errors use the group key.
{
"participants": "Must be at most 5 items",
"participants.1.email": "Enter a valid email"
}engine.errors, stepErrors, setErrors(), the errors prop and submit results all use these paths. When an item is removed, the errors of later items move with them.
Engine API
engine.addItem('participants');
engine.setItemValue('participants', 0, 'email', 'ada@example.com');
engine.setValue('participants.0.email', 'ada@example.com');
engine.removeItem('participants', 1);
engine.items('participants');items(key) returns { id, fields } for each item: a stable id for list keys and the visible fields of that item. See the FormEngine reference.
Rendering and focus
React and Vue render a group as a fieldset with one labelled card per item:
<fieldset class="fh-group" id="…-participants">
<legend class="fh-field__label">Participants</legend>
<p class="fh-field__helper fh-group__helper">Up to 5 people per registration</p>
<div class="fh-group__body">
<div class="fh-group__item" role="group" aria-labelledby="…-participants--0">
<div class="fh-group__header">
<span class="fh-group__title" id="…-participants--0" tabindex="-1">Participant 1</span>
<button class="fh-group__remove" aria-label="Remove Participant 1">Remove</button>
</div>
<div class="fh-group__fields"><!-- item fields --></div>
</div>
<button class="fh-group__add">Add participant</button>
</div>
<p class="fh-group__status" role="status">Participant 2 added</p>
</fieldset>- Add focuses the first field of the new item.
- Remove on an item with answers opens a modal dialog first:
Remove Participant 2?, a short warning, then Cancel andRemove Participant 2. Focus starts on Cancel. Escape, Cancel, the close button or a click on the backdrop closes it and returns focus to the item's Remove button. Empty items, or items that hold only their defaults, go at once. Turn it off withconfirmRemove={false}(React) or:confirm-remove="false"(Vue). - Remove focuses the heading of the next item, or Add when no item follows.
- A polite status region announces
{item} addedand{item} removed. - After a failed Submit, focus goes to the first invalid field. A count error focuses Add.
- Error summary links point at item fields and read
Email (Participant 2): Enter a valid email.
The Add and Remove buttons reuse the action button classes, so theme tokens apply. Item cards use .fh-group__item and --fh-card-padding. The stylesheet is optional, see Optional baseline styles.
Custom markup
Pass group and groupItem in components to own the markup. Field components inside items still come from the same map.
import type { GroupComponentProps } from '@formhaus/react';
function Participants({ label, items, canAdd, add }: GroupComponentProps) {
return (
<section>
<h3>{label}</h3>
{items.map((item) => (
<article key={item.id} aria-labelledby={item.titleId}>
<h4 id={item.titleId} tabIndex={-1}>{item.title}</h4>
{item.children}
{item.canRemove && <button type="button" onClick={item.remove}>{item.removeLabel}</button>}
</article>
))}
{canAdd && <button type="button" onClick={add}>Add</button>}
</section>
);
}
<FormRenderer definition={definition} components={{ group: Participants }} onSubmit={send} />;| Prop | Meaning |
|---|---|
id, group, label, helperText, error, describedBy | Group texts and ids |
items | { id, index, title, titleId, canRemove, remove, requestRemove, hasContent, removeLabel, removeName, disabled } per item. React adds children with the item fields. Vue adds fields, a component that renders them |
canAdd, add, remove(id), requestRemove(id) | Item operations within minLength and maxLength. requestRemove asks first when the item has answers |
addLabel, removeLabel, status, disabled | Button texts, the live announcement and the busy state |
ItemComponent (React), itemComponent (Vue) | The resolved groupItem component |
Give the item title the titleId id and tabindex="-1", the item's Remove button the id ${titleId}-remove, and the Add button the fh-group__add class, so focus moves after Add and Remove. DefaultGroup and DefaultGroupItem are exported for wrapping.
The confirmDialog entry replaces the remove dialog. It renders while a removal waits for an answer and gets id, open, title, description, confirmLabel, cancelLabel, confirm() and cancel(). DefaultConfirmDialog is a native <dialog> opened with showModal().
In Vue, render the item fields with <component :is="item.fields" />.
Without a renderer
FormEngine alone is enough for Svelte, vanilla JS or any other UI:
import { FormEngine } from '@formhaus/core';
const engine = new FormEngine(definition);
engine.subscribe(render);
function render() {
for (const [index, item] of engine.items('participants').entries()) {
for (const field of item.fields) {
const path = `participants.${index}.${field.key}`;
draw(path, field, engine.errors[path]);
}
}
}
engine.addItem('participants');
engine.setValue('participants.0.email', 'ada@example.com');
engine.removeItem('participants', 0);items() returns a stable id for keyed lists and the visible fields of each item. Errors use the same paths.
Server validation
validateSubmission() checks groups too:
- The value must be an array of plain objects. More than
maxLengthitems, and more than 100, fails with thevaluemessage. - Unknown item keys are dropped.
- Every visible item field gets its type check and rules. Errors come back by path.
Upgrade the server before the clients. Core 0.13 and older treat a group as a custom field and pass its value through without checks.
Translations
Group texts live at fields.participants.label, itemLabel, addLabel, removeLabel and helperText. Item fields live at fields.participants.fields.email.label. A translated itemLabel must keep {index}. The label catalog adds addItem, removeItem, itemLabel, itemAdded, itemRemoved, removeItemNamed, removeItemPrompt, removeItemBody and cancel. See Translations.