RepeaterField
Form field component for repeating an arbitrary slot template over a list, with add, remove, and optional row reordering.
Component
Props
| Props | Required | Default | Type |
|---|---|---|---|
id | true | — | string |
label | — | — | string |
modelValue | — | [] | unknown[] |
defaultValue | — | {} | unknown |
addItemAriaLabel | — | 'Add item' | string |
removeItemAriaLabel | — | 'Remove item' | string |
addButtonText | — | 'Add item' | string |
removeButtonText | — | 'Remove item' | string |
actionsOrientation | — | Orientation.VERTICAL | Orientation |
sortingType | — | RepeatingFieldSortingType.NONE | RepeatingFieldSortingType |
moveUpAriaLabel | — | 'Move item up' | string |
moveDownAriaLabel | — | 'Move item down' | string |
moveUpButtonText | — | 'Move up' | string |
moveDownButtonText | — | 'Move down' | string |
dragHandleAriaLabel | — | 'Drag to reorder item' | string |
moveUpIcon | — | 'mdi:arrow-up' | string |
moveDownIcon | — | 'mdi:arrow-down' | string |
dragHandleIcon | — | 'mdi:drag-vertical' | string |
dragPlaceholderText | — | 'Drop here' | string |
showDragPlaceholderText | — | true | boolean |
dragPlaceholderClass | — | — | string |
dragPlaceholderTextClass | — | — | string |
helpText | — | — | string |
helpTextPosition | — | Position.BOTTOM | Position |
error | — | '' | string |
required | — | false | boolean |
showOptionalLabel | — | true | boolean |
optionalLabel | — | — | string |
validator | — | — | Function |
disabled | — | false | boolean |
Usage
id
Sets the field identifier, used as the label's for target and exposed to the default slot so row controls can build their own unique ids.
<template>
<RepeaterField
id="members"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
label
Sets the field label displayed above the items.
<template>
<RepeaterField
id="members"
label="Members"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
modelValue
Controls the list of items rendered by the repeater via v-model. Each entry is passed as-is to the default slot, so items can be any shape: an object, a string, a number, or anything your slot content knows how to render.
<template>
<RepeaterField
id="members"
:defaultValue="{ name: '' }"
v-model="items"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
<script setup lang="ts">
const items = ref([{ name: 'First item' }])
</script>
defaultValue
Sets the value cloned into modelValue whenever a new item is added. When modelValue is empty, one clone of defaultValue is also used to seed the initial row.
<template>
<RepeaterField
id="members"
:defaultValue="{ name: '', email: '' }"
v-model="items"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
addItemAriaLabel
Sets the accessible label for the add-item icon button, shown on the last row.
<template>
<RepeaterField
id="members"
addItemAriaLabel="Add member"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
removeItemAriaLabel
Sets the accessible label for the remove-item icon button, shown on every row when there is more than one item.
<template>
<RepeaterField
id="members"
removeItemAriaLabel="Remove member"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
addButtonText
Sets the visible text on the add-item button rendered as a full-width ActionButton, shown at every breakpoint when actionsOrientation is horizontal, and as the mobile-only fallback when actionsOrientation is vertical.
<template>
<RepeaterField
id="members"
addButtonText="Add member"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
removeButtonText
Sets the visible text on the remove-item button rendered as a full-width ActionButton, shown at every breakpoint when actionsOrientation is horizontal, and as the mobile-only fallback when actionsOrientation is vertical.
<template>
<RepeaterField
id="members"
removeButtonText="Remove member"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
actionsOrientation
Controls how the add/remove/move row actions are laid out. vertical (the default) places compact icon buttons in a column beside the slot content, which fits best when the slot content is short; on mobile it always falls back to a full-width ActionButton row below the content, stacked in a column, since a side column doesn't scale down well. horizontal always renders that same full-width button row below the content, at every breakpoint: a better fit when the slot content is tall or the row needs to stack regardless of screen size. It uses the Orientation enum.
<template>
<RepeaterField
id="members"
:actionsOrientation="Orientation.HORIZONTAL"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
Options
| Value | Description |
|---|---|
VERTICAL | vertical |
HORIZONTAL | horizontal |
sortingType
Controls how users can reorder items. none disables reordering, buttons shows up/down icon buttons next to every row, and drag shows a drag handle for native drag-and-drop reordering with a dashed placeholder marking the drop position. Unlike a fixed-column field, every row is reorderable since there is no trailing add-row. It uses the RepeatingFieldSortingType enum.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
v-model="items"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
Options
| Value | Description |
|---|---|
NONE | none |
BUTTONS | buttons |
DRAG | drag |
moveUpAriaLabel
Sets the accessible label for the move-item-up icon button, shown when sortingType is buttons.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
moveUpAriaLabel="Move member up"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
moveDownAriaLabel
Sets the accessible label for the move-item-down icon button, shown when sortingType is buttons.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
moveDownAriaLabel="Move member down"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
moveUpButtonText
Sets the visible text on the move-up button rendered as a full-width ActionButton in the horizontal/mobile row, shown when sortingType is buttons.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
moveUpButtonText="Move member up"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
moveDownButtonText
Sets the visible text on the move-down button rendered as a full-width ActionButton in the horizontal/mobile row, shown when sortingType is buttons.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
moveDownButtonText="Move member down"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
dragHandleAriaLabel
Sets the accessible label for the drag handle, shown when sortingType is drag.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.DRAG"
dragHandleAriaLabel="Drag to reorder member"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
moveUpIcon
Sets the icon used on the move-item-up button.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
moveUpIcon="mdi:chevron-up"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
moveDownIcon
Sets the icon used on the move-item-down button.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.BUTTONS"
moveDownIcon="mdi:chevron-down"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
dragHandleIcon
Sets the icon used on the drag handle.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.DRAG"
dragHandleIcon="mdi:dots-grid"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
dragPlaceholderText
Sets the text passed to the underlying DragPlaceholder's text prop, shown in the dashed drop-position placeholder when showDragPlaceholderText is true.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.DRAG"
dragPlaceholderText="Drop member here"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
showDragPlaceholderText
Sets the DragPlaceholder's showText prop, controlling whether dragPlaceholderText is rendered inside the drop-position placeholder shown when sortingType is drag.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.DRAG"
:showDragPlaceholderText="true"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
dragPlaceholderClass
Sets the class applied to the DragPlaceholder's root element, letting you override its default border, background, or spacing.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.DRAG"
dragPlaceholderClass="border-border-primary-brand-default"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
dragPlaceholderTextClass
Sets the DragPlaceholder's textClass prop, letting you override the default styling of dragPlaceholderText when showDragPlaceholderText is true.
<template>
<RepeaterField
id="members"
:sortingType="RepeatingFieldSortingType.DRAG"
:showDragPlaceholderText="true"
dragPlaceholderTextClass="text-sm font-semibold"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
helpText
Shows helper text below the field when there is no error.
<template>
<RepeaterField
id="members"
helpText="Add one or more members"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
helpTextPosition
Sets the position of the help text relative to the field. It uses the Position enum.
<template>
<RepeaterField
id="members"
:helpTextPosition="Position.TOP"
helpText="Appears above the field"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
Options
| Value | Description |
|---|---|
TOP | top |
BOTTOM | bottom |
error (v-model:error)
Displays error text below the field and applies error styling to the label.
<template>
<RepeaterField
id="members"
v-model="items"
v-model:error="errorMessage"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
<script setup lang="ts">
const errorMessage = ref('')
</script>
required
Enables validation execution together with validator.
<template>
<RepeaterField
id="members"
v-model="items"
v-model:error="errorMessage"
:required="true"
:validator="validateMembers"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
showOptionalLabel
When the field is not required, shows an "(optional)" hint next to the label. Set to false to hide it. The hint text defaults to a global setting that can be overridden project-wide, and can also be overridden per field with the optionalLabel prop.
<template>
<RepeaterField id="members" label="Members" :showOptionalLabel="false" />
</template>
optionalLabel
Overrides the "(optional)" hint text for this specific field, taking priority over the global default.
<template>
<RepeaterField id="members" label="Members" optionalLabel="(not required)" />
</template>
validator
Validation callback for the whole items array. Used together with required and v-model:error.
<template>
<RepeaterField
id="members"
v-model="items"
v-model:error="errorMessage"
:required="true"
:validator="validateMembers"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
<script setup lang="ts">
const validateMembers = (value: unknown[]) => (value.length ? null : 'Add at least one member.')
</script>
disabled
Disables the drag handle, move buttons, and add/remove action buttons for every row.
<template>
<RepeaterField
id="members"
disabled
v-model="items"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-name-${index}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
Slots
| Name | Description |
|---|---|
default | Template rendered for each item; can contain any content, from a single input to a full multi-field row. |
Slot exposed props
<template>
<RepeaterField
id="members"
v-model="items"
v-slot="{ item, index, id }"
>
<InputField
:id="`${id}-item-${index}`"
:ariaLabel="`Item ${index + 1}`"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
</RepeaterField>
</template>
The slot exposes: item, index, id.
Full example
<template>
<Form @submit="handleSubmit">
<FormRow>
<RepeaterField
id="members"
label="Team members"
helpText="Add one or more members"
:defaultValue="{ name: '', email: '' }"
v-model="formData.members"
v-model:error="formErrors.members"
:sortingType="RepeatingFieldSortingType.DRAG"
:required="true"
:validator="validateMembers"
v-slot="{ item, index, id }"
>
<div class="flex flex-col gap-2">
<InputField
:id="`${id}-name-${index}`"
:ariaLabel="`Member ${index + 1} name`"
placeholder="Name"
:modelValue="item.name"
@update:model-value="value => (item.name = value)"
/>
<InputField
:id="`${id}-email-${index}`"
:ariaLabel="`Member ${index + 1} email`"
type="email"
placeholder="Email"
:modelValue="item.email"
@update:model-value="value => (item.email = value)"
/>
</div>
</RepeaterField>
</FormRow>
<FormActions>
<ActionButton
type="submit"
text="Save members"
:styleType="ButtonStyleType.PRIMARY_BRAND_FILLED"
/>
</FormActions>
</Form>
</template>
<script setup lang="ts">
const { $toast } = useNuxtApp()
const formData = reactive({
members: [{ name: '', email: '' }],
})
const validateMembers = (value: { name: string, email: string }[]) =>
value.every(member => member.name && member.email) ? null : 'Complete all member fields.'
const { formErrors, resetForm, validateFormFields } = useForm({
formData,
requiredFields: ['members'],
validators: {
members: validateMembers,
},
})
const handleSubmit = () => {
const isValid = validateFormFields()
if (!isValid) {
$toast.error('Some fields contain errors', {
toastId: 'members-form-error',
})
return
}
$toast.success('Members saved successfully', {
toastId: 'members-form-success',
})
resetForm()
}
</script>