Repeating list
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.
<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.
<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
| Prop | Type | Default | Description |
|---|---|---|---|
| itemLabelrequired | string | — | One row, singular and lower case: "referral source". |
| modelValuerequired | T[] | — | — |
| newRowrequired | () => T | — | A new row's starting values. |
| addLabel | string | undefined | The Add button's words; "Add a(n) <item>", then "Add another <item>", unless given. |
| announce | (change: { type: "added" | "removed"; title: string; count: number; }) => string | undefined | What a screen reader hears when a row is added or removed; "Part 3 added. 3 in the list." unless given. |
| compact | boolean | false | One line per row, no heading or card: for rows of a single short field. |
| editLabel | string | "Edit" | The folded row's Edit button's word; named the same way. |
| errors | Record<string, string> | undefined | Errors by row key (rowKey, or the key the #row slot is given), shown under the row. |
| focusSelector | string | "input, select, textarea, button[role=\"radio\"]" | Where focus lands in a row that is added or opened: the first match in the row. |
| max | number | undefined | Most rows allowed: Add is disabled at it, and says so. |
| maxLabel | string | undefined | Shown beside the disabled Add button at `max`; "That's the most you can add here (12)." unless given. |
| min | number | 0 | Rows always on screen: rows are added up to it when it mounts, and Remove hides at it. |
| removable | (row: T) => boolean | undefined | Whether a row may be removed (default: every row, down to `min`). |
| removeLabel | string | "Remove" | The Remove button's word; its accessible name is this and the row's title: "Remove Part 3". |
| rowKey | (row: T) => string | undefined | A stable key per row (a saved record's id). Undefined for a new row. |
| rowTitle | (row: T, index: number) => string | undefined | The row's heading, and the name its buttons carry; "Referral source 2" unless given. Hidden in `compact`. |
Emits
| Event | Payload | Description |
|---|---|---|
| update:modelValue | [value: T[]] | — |
Slots
| Slot | Description |
|---|---|
| row | — |
| summary | — |