component

Repeating list

An editor for a list of rows with the same few fields, with Add, Remove, folding, per-row errors and announcements.

Forms often ask for "one or more" of something: referral sources, contacts, the parts of a split. ZRepeatingList owns the list: adding a row, removing one, folding rows to a summary, showing each row's error, moving focus, and announcing the count to screen readers. The fields are yours, in the #row slot, so a row can hold any inputs. It is generic: row in each slot has your row's type.

Folded to a summary

With a #summary slot, rows that are already there when the list mounts start folded to one line with an Edit button. New rows, and any row with an error, are open. row-key keeps a row's identity while it is edited, for rows that are saved records.

Referral source 1
Referral source 2

<script setup lang="ts">
interface Source { id?: string, name: string, company: string }

const sources = ref<Source[]>([
  { id: 'src-1', name: 'Pat Brown', company: 'Brown Architects' },
  { id: 'src-2', name: 'Sam Lee', company: 'Lee Plumbing' },
])
const newSource = (): Source => ({ name: '', company: '' })
const sourceKey = (s: Source) => s.id
</script>

<template>
  <div class="w-full max-w-lg">
    <ZRepeatingList
      v-model="sources"
      item-label="referral source"
      :row-key="sourceKey"
      :new-row="newSource"
    >
      <template #row="{ row }">
        <UFormField label="Name">
          <UInput v-model="row.name" />
        </UFormField>
        <UFormField label="Company">
          <UInput v-model="row.company" />
        </UFormField>
      </template>
      <template #summary="{ row }">{{ [row.name, row.company].filter(Boolean).join(' · ') }}</template>
    </ZRepeatingList>
  </div>
</template>

Compact, with a minimum and maximum

compact puts each row on one line with no heading or card, for rows of a single short field. min keeps that many rows on screen and hides Remove at it; max disables Add at it and says why.

<script setup lang="ts">
const parts = ref<{ amount: string }[]>([])
</script>

<template>
  <div class="w-full max-w-sm">
    <ZRepeatingList
      v-model="parts"
      item-label="part"
      compact
      :min="2"
      :max="4"
      :new-row="() => ({ amount: '' })"
    >
      <template #row="{ row, index }">
        <UInput v-model="row.amount" :aria-label="`Part ${index + 1} amount`" inputmode="decimal" placeholder="0.00" />
      </template>
    </ZRepeatingList>
  </div>
</template>

Errors per row

errors maps a row's key to its message, shown under the row. The #row slot gets the message's errorId to put on the input's aria-describedby.

Contact 1
Contact 2

<script setup lang="ts">
interface Contact { id: string, email: string }

const contacts = ref<Contact[]>([
  { id: 'c1', email: 'pat@example.com' },
  { id: 'c2', email: 'not an address' },
])
let next = 3
const newContact = (): Contact => ({ id: `c${next++}`, email: '' })

// Errors by row key, as a form's validation would set them.
const errors = computed(() => Object.fromEntries(contacts.value
  .filter((c) => c.email && !/^\S+@\S+\.\S+$/.test(c.email))
  .map((c) => [c.id, 'Enter an email address, like name@example.com.'])))
</script>

<template>
  <div class="w-full max-w-lg">
    <ZRepeatingList
      v-model="contacts"
      item-label="contact"
      :row-key="(c: Contact) => c.id"
      :new-row="newContact"
      :errors="errors"
    >
      <template #row="{ row, index, errorId }">
        <UFormField :label="`Email ${index + 1}`">
          <UInput v-model="row.email" type="email" :aria-describedby="errorId" :aria-invalid="!!errorId" />
        </UFormField>
      </template>
    </ZRepeatingList>
  </div>
</template>

API

Props

PropTypeDefaultDescription
itemLabelrequiredstring—One row, singular and lower case: "referral source".
modelValuerequiredT[]——
newRowrequired() => T—A new row's starting values.
addLabelstringundefinedThe Add button's words; "Add a(n) <item>", then "Add another <item>", unless given.
announce(change: { type: "added" | "removed"; title: string; count: number; }) => stringundefinedWhat a screen reader hears when a row is added or removed; "Part 3 added. 3 in the list." unless given.
compactbooleanfalseOne line per row, no heading or card: for rows of a single short field.
editLabelstring"Edit"The folded row's Edit button's word; named the same way.
errorsRecord<string, string>undefinedErrors by row key (rowKey, or the key the #row slot is given), shown under the row.
focusSelectorstring"input, select, textarea, button[role=\"radio\"]"Where focus lands in a row that is added or opened: the first match in the row.
maxnumberundefinedMost rows allowed: Add is disabled at it, and says so.
maxLabelstringundefinedShown beside the disabled Add button at `max`; "That's the most you can add here (12)." unless given.
minnumber0Rows always on screen: rows are added up to it when it mounts, and Remove hides at it.
removable(row: T) => booleanundefinedWhether a row may be removed (default: every row, down to `min`).
removeLabelstring"Remove"The Remove button's word; its accessible name is this and the row's title: "Remove Part 3".
rowKey(row: T) => stringundefinedA stable key per row (a saved record's id). Undefined for a new row.
rowTitle(row: T, index: number) => stringundefinedThe row's heading, and the name its buttons carry; "Referral source 2" unless given. Hidden in `compact`.

Emits

EventPayloadDescription
update:modelValue[value: T[]]—

Slots

SlotDescription
row—
summary—