Skip to content

Repeatable groups ​

A group repeats a set of fields, and people add or remove items. Requires @formhaus/core 0.14 and "formatVersion": "1.1".

json
{
  "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:

json
{ "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 ​

PropertyTypeDescription
keystringKey of the item array.
type"group"
labelstringGroup heading.
helperTextstringText under the heading.
itemLabelstringItem heading. {index} is the item number from 1, {label} the group label. Default {label} {index}.
addLabel, removeLabelstringButton texts. Default Add and Remove.
defaultItemsintegerNumber of items the group starts with.
show, showAnycondition[]Group visibility. A hidden group is cleared, and when it shows again it starts with defaultItems new items.
validationobjectrequired, minLength, maxLength, their messages and validator.
fieldsfield[]Fields of one item.

Item count ​

  • required: true means at least one item.
  • minLength and maxLength count items. Their default messages are Must be at least {min} items and Must be at most {max} items.
  • Renderers hide Add at maxLength, or at 100 items without it. They hide Remove at minLength, or at 1 item when required.
  • validator receives 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-scope error. Test the group itself instead, such as { "field": "participants", "notEmpty": true }.
  • matchField follows 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.

json
{
  "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 ​

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

html
<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 and Remove 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 with confirmRemove={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} added and {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.

tsx
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} />;
PropMeaning
id, group, label, helperText, error, describedByGroup 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, disabledButton 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:

ts
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 maxLength items, and more than 100, fails with the value message.
  • 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.

Formhaus is MIT licensed. Legal