Skip to content

Arrays & suggestions ​

The array kind edits a list of sub-records. This page covers the patterns that go beyond the basic setup.

The shape ​

ts
{
  fields: [
    {
      name: 'authors',
      kind: 'array',
      displayName: 'Authors',
      newButtonLabel: 'Add Author',
      of: [
        { name: 'preName', displayName: 'First name' },
        { name: 'lastName', displayName: 'Last name' },
        {
          name: 'role',
          kind: 'select',
          displayName: 'Role',
          options: ['Author', 'Editor', 'Reviewer'],
          defaultValue: 'Author',
        },
      ],
      renderEntry: (entry) =>
        `${String(entry['preName'])} ${String(entry['lastName'])} (${String(entry['role'])})`,
      isValidRecord: (entry) => String(entry['lastName'] ?? '').trim().length > 0,
      suggested: [
        { preName: 'Ada', lastName: 'Lovelace', role: 'Author' },
        { preName: 'Grace', lastName: 'Hopper', role: 'Reviewer' },
      ],
    },
  ],
}
Rendered form

Values

—

The rendered list is: optional suggestions, then one row per entry with Edit and Remove, then the add button.

Entries are full forms ​

of is an ordinary array of field descriptors, so an entry dialog can use any field kind — including another array. Nesting works, and each level gets its own dialog on the modal stack.

ts
of: [
  { name: 'name', displayName: 'Name' },
  {
    name: 'affiliations',
    kind: 'array',
    of: [{ name: 'institution', displayName: 'Institution' }],
  },
];

Entry dialogs get their own tabs, reactivity, and buttons — they are not a reduced form.

Reaching the parent ​

Inside an entry, ctx.parentData is the enclosing form's values:

ts
of: [
  {
    name: 'affiliation',
    displayName: 'Affiliation',
    isActive: ({ parentData }) => parentData?.['kind'] === 'academic',
  },
];

For deeper nesting, ctx.stackData lists every open dialog's values, outermost first.

Add vs. edit ​

Editing seeds each field with the entry's current value. To change the field set for the edit case, use mapFieldsOnEdit:

ts
mapFieldsOnEdit: (fields, entry) => [
  ...fields.filter((f) => f.name !== 'kind'),
  { name: 'kindLabel', kind: 'message', message: `Kind: ${String(entry['kind'])}` },
],

Any descriptor callback can also branch on ctx.isEditingArrayEntry:

ts
{
  name: 'preName',
  tab: ({ isEditingArrayEntry }) => (isEditingArrayEntry ? 'Edit author' : 'New author'),
}

Validating before an entry can be added ​

isValidRecord gates the entry dialog's confirm button. It runs on every change within that dialog and may be async:

ts
isValidRecord: async (entry) => {
  const email = String(entry['email'] ?? '');
  return email.includes('@') && !(await isAlreadyInvited(email));
},

It also receives the outer field context as a second argument, if you need the parent form's data to decide.

Suggestions ​

Suggestions are pre-filled entries offered as checkboxes above the list. They suit "we already know some likely answers" cases — collaborators on a document, authors from an imported file:

ts
{
  name: 'authors',
  kind: 'array',
  of: authorFields,
  suggested: [
    { preName: 'Ada', lastName: 'Lovelace', role: 'Author' },
    { preName: 'Grace', lastName: 'Hopper', role: 'Reviewer' },
  ],
}

Ticking one adds it to the value; unticking removes it. They are merged into the same array as manually added entries, so getValues() gives you one uniform list.

Suggestions can be computed from the form:

ts
suggested: ({ data, parentData }) =>
  knownCollaborators(parentData?.['documentId'])
    .filter((person) => person.role === data['filterRole']),

Each suggestion is summarised with the same renderEntry as real entries, so the list reads consistently.

Deduplication ​

A suggestion identical to an entry the user already added by hand is not added twice. Comparison is structural, so an accepted suggestion that the user then edits is treated as a distinct record.

Reacting to list changes ​

onChange fires after an entry is added, edited or removed, once pending work has settled:

ts
onChange: (values) => {
  autosave(values);
},

It receives the whole form's values, not just the array, so you can persist in one step.

Styling ​

The default stylesheet covers the list; the hooks are .dl-form-array-of-entry, its .edit-array-of-btn / .delete-array-of-btn, the .dl-form-array-of-add-entry button, and .dl-form-array-of-suggestion inside .dl-form-array-suggested-container.

Each entry dialog also carries a class naming its field — .form-for-array-of-authors — so you can style or target one specific list's dialog. See the DOM contract.

Released under the MIT License.