feat(17-02): repeater field and dependent dropdown states

- RepeaterField: rows with drag handle, index, titleFrom title, error chip,
  move up/down (buttons, ArrowUp/ArrowDown on the handle, native drag and
  drop), expand/collapse, remove (asks only for a row holding a value),
  add with nested defaults, maxItems counter, empty state, collapse all;
  every change edits form state only
- 422 row paths (content.3.answer) open the row, mark it and focus the
  nested control; the banner no longer repeats row messages
- DropdownField dependsOn: waiting, loading, not used, empty, loaded with
  depth indent, failed with retry; cached per sibling value, last request
  wins, a stale value is cleared after a sibling change and announced
- FormView provides form feedback (errors and notices by row path, the
  polite live region, its confirm dialog) and the options cache; FormGrid
  provides the sibling scope for repeater rows
- repeater.* and form.dependent_* phrase keys (en, pl), smoke specs, docs
This commit is contained in:
2026-10-12 16:08:23 +02:00
parent 4a5e896429
commit d3bf5d8f86
17 changed files with 1432 additions and 60 deletions

View File

@@ -1,17 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown } from '@lucide/vue'
import type { FormOption } from '../../../api/types'
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ChevronDown, CircleAlert, RotateCcw } from '@lucide/vue'
import { api } from '../../../api/client'
import type { AdminDependentOptionsResult, FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import Button from '../../ui/Button.vue'
import { controlClass, type FieldControlProps } from '../control'
import { DEPENDENT_CACHE, FIELD_SCOPE, FORM_FEEDBACK } from '../formContext'
// type: dropdown. With `dependsOn` (D-08, UI-SPEC "Dependent dropdown") the
// options come from GET …/fields/{field}/dependent-options for the sibling's
// current value (same form, or same repeater row through the row's grid
// scope), and the control shows the waiting, loading, not-used, empty,
// loaded and failed states. Answers are cached per sibling value for the
// form view and a late answer for an old sibling value is dropped. After the
// sibling changes, a value the new options no longer offer is cleared to
// null (the form becomes dirty) and announced in the form's live region; on
// load a stored value no option matches only shows the placeholder.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value'] | null] }>()
const EMPTY = 'empty'
const PLACEHOLDER = 'placeholder'
const INDENT = ' '.repeat(4)
const options = computed(() => props.field.options ?? [])
type Dependent = 'waiting' | 'loading' | 'unused' | 'empty' | 'ready' | 'failed'
const scope = inject(FIELD_SCOPE, null)
const feedback = inject(FORM_FEEDBACK, null)
const cache = inject(DEPENDENT_CACHE, null) ?? new Map<string, AdminDependentOptionsResult>()
const sibling = computed(() => props.field.dependsOn ?? '')
const dependent = computed(() => sibling.value !== '')
const state = ref<Dependent>('waiting')
const loaded = ref<FormOption[]>([])
/** The sibling's value as the route takes it: a string, or null when empty. */
const siblingValue = computed<string | null>(() => {
const value = scope?.values.value[sibling.value]
if (value === null || value === undefined || value === '' || typeof value === 'object') {
return null
}
return String(value)
})
const siblingField = computed(() => scope?.fields.value.find((field) => field.name === sibling.value))
const siblingLabel = computed(() => siblingField.value?.label || sibling.value)
const siblingOptionLabel = computed(() => {
const option = (siblingField.value?.options ?? []).find((item) => same(item.value, siblingValue.value))
return option?.label ?? siblingValue.value ?? ''
})
const options = computed<FormOption[]>(() => (dependent.value ? loaded.value : (props.field.options ?? [])))
function same(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) {
@@ -20,6 +59,112 @@ function same(a: unknown, b: unknown): boolean {
return String(a) === String(b)
}
function hasValue(): boolean {
return props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== ''
}
function clearValue(): void {
if (!hasValue()) {
return
}
emit('update:modelValue', null)
feedback?.announce(t('backend::lang.form.dependent_cleared', { field: props.field.label || props.field.name }))
}
function adopt(result: AdminDependentOptionsResult, changed: boolean): void {
if (!result.applies) {
loaded.value = []
state.value = 'unused'
if (changed) {
clearValue()
}
return
}
loaded.value = result.options.map((option) => ({
value: option.value,
label: INDENT.repeat(Math.max(0, option.depth)) + option.label,
}))
state.value = loaded.value.length > 0 ? 'ready' : 'empty'
if (changed && hasValue() && !loaded.value.some((option) => same(option.value, props.modelValue))) {
clearValue()
}
}
let sequence = 0
// Whether the request that failed followed a sibling edit: Retry keeps it.
let failedAfterChange = false
/** Loads the options for the sibling's current value; `changed` after a sibling edit. */
async function load(changed: boolean): Promise<void> {
const current = ++sequence
const value = siblingValue.value
if (value === null) {
loaded.value = []
state.value = 'waiting'
if (changed) {
clearValue()
}
return
}
const path = scope?.path(props.field.name) ?? props.field.name
const key = `${path}\u0000${value}`
const cached = cache.get(key)
if (cached) {
adopt(cached, changed)
return
}
if (!props.source) {
failedAfterChange = changed
state.value = 'failed'
return
}
state.value = 'loading'
try {
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options', {
params: {
path: { ...props.source, field: path },
// values[<sibling>]=<value>: a dynamic key the OpenAPI document does not list.
query: { [`values[${sibling.value}]`]: value } as never,
},
})
if (current !== sequence) {
return
}
if (!data) {
failedAfterChange = changed
state.value = 'failed'
return
}
cache.set(key, data.data)
adopt(data.data, changed)
} catch {
if (current === sequence) {
failedAfterChange = changed
state.value = 'failed'
}
}
}
function retry(): void {
void load(failedAfterChange)
}
watch(siblingValue, (next, previous) => {
if (dependent.value && next !== previous) {
void load(true)
}
})
onMounted(() => {
if (dependent.value) {
void load(false)
}
})
onBeforeUnmount(() => {
sequence++
})
// The <select> works on indexes so option values keep their JSON type
// (string, number or boolean) when they are sent back.
const selected = computed(() => {
@@ -27,13 +172,37 @@ const selected = computed(() => {
if (index >= 0) {
return String(index)
}
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
if (props.field.emptyOption !== undefined && !hasValue()) {
return EMPTY
}
return PLACEHOLDER
})
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
/** The single disabled option text of a dependent state other than ready. */
const stateText = computed(() => {
switch (state.value) {
case 'waiting':
return t('backend::lang.form.dependent_waiting', { field: siblingLabel.value })
case 'loading':
return t('backend::lang.form.loading_options')
case 'unused':
return t('backend::lang.form.dependent_not_used')
case 'empty':
return t('backend::lang.form.dependent_empty', { type: siblingOptionLabel.value })
case 'failed':
return t('backend::lang.form.dependent_failed')
default:
return ''
}
})
const blocked = computed(() => dependent.value && state.value !== 'ready')
const muted = computed(() => blocked.value || selected.value === EMPTY || selected.value === PLACEHOLDER)
const hintId = computed(() => `${props.controlId}-dependent`)
const describedBy = computed(() =>
[props.describedBy ?? '', dependent.value && (state.value === 'empty' || state.value === 'failed') ? hintId.value : '']
.filter(Boolean)
.join(' '),
)
function onChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value
@@ -49,33 +218,52 @@ function onChange(event: Event): void {
</script>
<template>
<div class="relative">
<select
:id="controlId"
:name="field.name"
:value="selected"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
class="h-input appearance-none pr-10"
@change="onChange"
>
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<!-- A stored value no option matches shows the placeholder, never a
wrong choice (also next to an emptyOption). -->
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }}
</option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
{{ option.label }}
</option>
</select>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<div class="flex flex-col gap-1.5" :data-dependent="dependent ? state : undefined">
<div class="relative">
<select
:id="controlId"
:name="field.name"
:value="blocked ? 'state' : selected"
:disabled="blocked || undefined"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-busy="dependent && state === 'loading' ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
class="h-input appearance-none pr-10 disabled:cursor-not-allowed"
@change="onChange"
>
<option v-if="blocked" value="state" class="text-muted">{{ stateText }}</option>
<template v-else>
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<!-- A stored value no option matches shows the placeholder, never a
wrong choice (also next to an emptyOption). -->
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }}
</option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
{{ option.label }}
</option>
</template>
</select>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
</div>
<p v-if="dependent && state === 'empty'" :id="hintId" data-dependent-hint class="text-[13px] text-muted">
{{ stateText }}
</p>
<div v-else-if="dependent && state === 'failed'" class="flex flex-wrap items-center gap-2">
<p :id="hintId" role="alert" class="flex items-center gap-1.5 text-[13px] text-danger">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.form.dependent_failed') }}</span>
</p>
<Button variant="ghost" size="sm" :icon="RotateCcw" data-action="dependent-retry" @click="retry">
{{ t('backend::lang.form.dependent_retry') }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,465 @@
<script setup lang="ts">
import { computed, inject, nextTick, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from '@lucide/vue'
import type { AdminRecord, FormField } from '../../../api/types'
import { t, tc } from '../../../app/i18n'
import Button from '../../ui/Button.vue'
import { type FieldControlProps } from '../control'
import { FORM_FEEDBACK } from '../formContext'
import FormGrid from '../FormGrid.vue'
import PreviewField from '../PreviewField.vue'
import { inertFragment } from '../richeditor/lossy'
// type: repeater (D-07, UI-SPEC RepeaterField): a list of rows, each a
// nested FormGrid of the repeater's form.fields. Rows add, remove (asking
// first when the row holds a value), reorder by drag, by ArrowUp/ArrowDown on
// the handle and by the move buttons, and collapse; every change only edits
// the form value, which the record's Save sends. Row errors and notices come
// from the form's feedback under `<field>.<index>.<nested>`. A collapsed row
// unmounts its body (its richeditor is not running); its values stay in the
// form value. Read-only shows every row expanded through PreviewField.
// The expand button is a plain aria-expanded/aria-controls disclosure and
// the body a v-if: it mounts in the same tick, so a 422 that opens a row can
// focus the nested control at once.
//
// Only the registry imports this module, so the registry always evaluates
// first and this module's FormGrid -> registry import cycle is safe.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: AdminRecord[]] }>()
const feedback = inject(FORM_FEEDBACK, null)
const nested = computed<FormField[]>(() => props.field.form?.fields ?? [])
const readOnly = computed(() => !!props.field.readOnly)
const rows = computed<AdminRecord[]>(() =>
Array.isArray(props.modelValue)
? props.modelValue.map((row) => (row !== null && typeof row === 'object' && !Array.isArray(row) ? (row as AdminRecord) : {}))
: [],
)
const maxItems = computed(() => (typeof props.field.maxItems === 'number' ? props.field.maxItems : null))
const minItems = computed(() => (typeof props.field.minItems === 'number' ? props.field.minItems : 0))
const addLabel = computed(() => props.field.prompt || t('backend::lang.repeater.add'))
const labelledBy = computed(() => `${props.controlId}-label`)
// Stable keys per row: v-for identity, element ids and the expanded state.
let counter = 0
const keys = ref<string[]>([])
const collapsed = ref<Set<string>>(new Set())
let lastEmitted: unknown = undefined
function newKey(): string {
counter += 1
return `r${counter}`
}
watch(
() => props.modelValue,
(value) => {
if (value === lastEmitted && keys.value.length === rows.value.length) {
return
}
// A value from outside (load, the save's answer): keep the keys when the
// row count is unchanged, so ids and expanded rows stay put.
if (keys.value.length !== rows.value.length) {
keys.value = rows.value.map(() => newKey())
collapsed.value = new Set()
}
},
{ immediate: true },
)
function commit(next: AdminRecord[], nextKeys: string[]): void {
keys.value = nextKeys
lastEmitted = next
emit('update:modelValue', next)
}
function isEmptyValue(value: unknown): boolean {
return value === null || value === undefined || value === '' || value === false || (Array.isArray(value) && value.length === 0)
}
function defaults(): AdminRecord {
const out: AdminRecord = {}
for (const field of nested.value) {
if (field.default !== undefined) {
out[field.name] = field.default
} else if (field.type === 'checkbox' || field.type === 'switch') {
out[field.name] = false
} else {
out[field.name] = null
}
}
return out
}
/** Whether removing the row loses something: a value other than empty or the default. */
function holdsValue(row: AdminRecord): boolean {
return nested.value.some((field) => {
const value = row[field.name]
if (isEmptyValue(value)) {
return false
}
return field.default === undefined || String(field.default) !== String(value)
})
}
function plainText(html: string): string {
return (inertFragment(html).textContent ?? '').replace(/\s+/g, ' ').trim()
}
function title(index: number): string {
const row = rows.value[index] ?? {}
const source = nested.value.find((field) => field.name === props.field.titleFrom)
const value = source ? row[source.name] : undefined
let text = ''
if (source && !isEmptyValue(value) && typeof value !== 'object') {
if (source.type === 'dropdown') {
text = (source.options ?? []).find((option) => String(option.value) === String(value))?.label ?? String(value)
} else if (source.type === 'richeditor') {
text = plainText(String(value))
} else {
text = String(value).trim()
}
}
return text !== '' ? text : t('backend::lang.repeater.untitled', { n: index + 1 })
}
function rowMap(map: Record<string, string[]> | undefined, index: number): Record<string, string[]> {
const prefix = `${props.field.name}.${index}.`
const out: Record<string, string[]> = {}
for (const [key, lines] of Object.entries(map ?? {})) {
if (key.startsWith(prefix) && key.length > prefix.length) {
out[key.slice(prefix.length)] = lines
}
}
return out
}
const rowErrors = computed(() => rows.value.map((_, index) => rowMap(feedback?.errors.value, index)))
const rowNotices = computed(() => rows.value.map((_, index) => rowMap(feedback?.notices.value, index)))
const errorCount = (index: number) => Object.keys(rowErrors.value[index] ?? {}).length
// A 422 opens every row with errors, so focusError reaches the control.
watch(rowErrors, (all) => {
const open = all.flatMap((errors, index) => (Object.keys(errors).length > 0 ? [keys.value[index]!] : []))
if (open.length > 0) {
const next = new Set(collapsed.value)
for (const key of open) {
next.delete(key)
}
collapsed.value = next
}
})
function isOpen(key: string): boolean {
return readOnly.value || !collapsed.value.has(key)
}
function setOpen(key: string, open: boolean): void {
const next = new Set(collapsed.value)
if (open) {
next.delete(key)
} else {
next.add(key)
}
collapsed.value = next
}
const anyOpen = computed(() => keys.value.some((key) => !collapsed.value.has(key)))
function toggleAll(): void {
collapsed.value = anyOpen.value ? new Set(keys.value) : new Set()
}
// Elements by row key: handles keep focus after a move or a removal.
const root = ref<HTMLElement | null>(null)
const addButton = ref<{ $el: HTMLElement } | null>(null)
function handleOf(key: string): HTMLElement | null {
return root.value?.querySelector<HTMLElement>(`[data-repeater-handle="${key}"]`) ?? null
}
function onNested(index: number, name: string, value: unknown): void {
const next = rows.value.map((row, at) => (at === index ? { ...row, [name]: value } : row))
commit(next, [...keys.value])
feedback?.clearError(`${props.field.name}.${index}.${name}`)
}
async function add(): Promise<void> {
if (readOnly.value || (maxItems.value !== null && rows.value.length >= maxItems.value)) {
return
}
const key = newKey()
commit([...rows.value, defaults()], [...keys.value, key])
await nextTick()
const row = root.value?.querySelector(`[data-repeater-key="${key}"]`)
row?.querySelector<HTMLElement>('[data-repeater-body] input, [data-repeater-body] select, [data-repeater-body] textarea, [data-repeater-body] [contenteditable="true"]')?.focus()
}
async function remove(index: number): Promise<void> {
const row = rows.value[index]
if (!row || readOnly.value || rows.value.length <= minItems.value) {
return
}
if (holdsValue(row) && feedback) {
const name = title(index)
const ok = await feedback.confirm({
message: t('backend::lang.repeater.remove_confirm', { title: name }),
confirmLabel: t('backend::lang.repeater.remove'),
danger: true,
})
if (!ok) {
return
}
}
const nextKeys = keys.value.filter((_, at) => at !== index)
commit(
rows.value.filter((_, at) => at !== index),
nextKeys,
)
await nextTick()
const focusKey = nextKeys[index] ?? nextKeys[index - 1]
const handle = focusKey ? handleOf(focusKey) : null
if (handle) {
handle.focus()
} else {
addButton.value?.$el.focus()
}
}
async function move(from: number, to: number): Promise<void> {
const total = rows.value.length
if (readOnly.value || from === to || from < 0 || to < 0 || from >= total || to >= total) {
return
}
const nextRows = [...rows.value]
const nextKeys = [...keys.value]
const [row] = nextRows.splice(from, 1)
const [key] = nextKeys.splice(from, 1)
nextRows.splice(to, 0, row!)
nextKeys.splice(to, 0, key!)
commit(nextRows, nextKeys)
await nextTick()
handleOf(key!)?.focus()
feedback?.announce(t('backend::lang.repeater.moved', { title: title(to), position: to + 1, total }))
}
function onHandleKeydown(event: KeyboardEvent, index: number): void {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
void move(index, event.key === 'ArrowUp' ? index - 1 : index + 1)
}
}
// Native drag and drop by the handle (as FileuploadField): the dragged row
// fades and a dashed slot marks where it lands.
const dragging = ref<number | null>(null)
const slot = ref<number | null>(null)
function onDragStart(event: DragEvent, index: number): void {
dragging.value = index
slot.value = null
event.dataTransfer?.setData('text/plain', keys.value[index] ?? '')
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(event: DragEvent, index: number): void {
if (dragging.value === null) {
return
}
event.preventDefault()
const target = event.currentTarget as HTMLElement
const box = target.getBoundingClientRect()
slot.value = event.clientY > box.top + box.height / 2 ? index + 1 : index
}
function onDrop(event: DragEvent): void {
if (dragging.value === null || slot.value === null) {
return
}
event.preventDefault()
const from = dragging.value
const to = slot.value > from ? slot.value - 1 : slot.value
onDragEnd()
void move(from, to)
}
function onDragEnd(): void {
dragging.value = null
slot.value = null
}
const iconButton =
'inline-flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-40'
</script>
<template>
<div
ref="root"
role="group"
:aria-labelledby="labelledBy"
:aria-describedby="describedBy || undefined"
:data-repeater="field.name"
class="flex flex-col gap-4"
>
<div
v-if="rows.length === 0"
data-repeater-empty
class="flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center"
>
<p class="font-semibold">{{ t('backend::lang.repeater.empty') }}</p>
<p v-if="!readOnly" class="max-w-[420px] text-[13px] text-muted">
{{ t('backend::lang.repeater.empty_hint', { prompt: addLabel }) }}
</p>
</div>
<template v-for="(row, index) in rows" :key="keys[index]">
<div
v-if="dragging !== null && slot === index && slot !== dragging && slot !== dragging + 1"
data-repeater-slot
class="h-12 rounded-inner border-2 border-dashed border-primary bg-sel"
/>
<div
:data-repeater-row="index"
:data-repeater-key="keys[index]"
:class="[errorCount(index) > 0 ? 'border-danger' : 'border-border', dragging === index ? 'opacity-50' : '']"
class="rounded-inner border bg-surface"
@dragover="onDragOver($event, index)"
@drop="onDrop"
>
<div
:class="isOpen(keys[index]!) ? 'rounded-t-inner border-b border-border' : 'rounded-inner'"
class="flex min-h-12 items-center gap-2 bg-subtle px-2"
>
<button
v-if="!readOnly"
type="button"
draggable="true"
:data-repeater-handle="keys[index]"
:aria-label="t('backend::lang.repeater.move', { title: title(index) })"
:class="iconButton"
class="cursor-grab"
@keydown="onHandleKeydown($event, index)"
@dragstart="onDragStart($event, index)"
@dragend="onDragEnd"
>
<GripVertical :size="16" aria-hidden="true" />
</button>
<span class="text-[13px] text-muted tabular-nums">{{ index + 1 }}.</span>
<span data-repeater-title class="min-w-0 flex-1 truncate font-semibold" :title="title(index)">{{ title(index) }}</span>
<span
v-if="errorCount(index) > 0"
data-repeater-errors
class="inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold text-danger"
>
{{ tc('backend::lang.form.tab_errors', errorCount(index)) }}
</span>
<div v-if="!readOnly" class="flex gap-1">
<button
type="button"
data-action="repeater-up"
:aria-label="t('backend::lang.repeater.move_up', { title: title(index) })"
:disabled="index === 0"
:class="iconButton"
@click="move(index, index - 1)"
>
<ChevronUp :size="16" aria-hidden="true" />
</button>
<button
type="button"
data-action="repeater-down"
:aria-label="t('backend::lang.repeater.move_down', { title: title(index) })"
:disabled="index === rows.length - 1"
:class="iconButton"
@click="move(index, index + 1)"
>
<ChevronDown :size="16" aria-hidden="true" />
</button>
<button
type="button"
data-action="repeater-toggle"
:aria-label="t(isOpen(keys[index]!) ? 'backend::lang.repeater.collapse' : 'backend::lang.repeater.expand', { title: title(index) })"
:aria-expanded="isOpen(keys[index]!) ? 'true' : 'false'"
:aria-controls="`${controlId}-${keys[index]}-body`"
:class="iconButton"
@click="setOpen(keys[index]!, !isOpen(keys[index]!))"
>
<ChevronDown
:size="16"
:class="isOpen(keys[index]!) ? 'rotate-180' : ''"
class="transition-transform duration-150"
aria-hidden="true"
/>
</button>
<button
type="button"
data-action="repeater-remove"
:aria-label="t('backend::lang.repeater.remove_named', { title: title(index) })"
:disabled="rows.length <= minItems"
:class="iconButton"
class="hover:bg-danger-soft hover:text-danger"
@click="remove(index)"
>
<Trash2 :size="16" aria-hidden="true" />
</button>
</div>
</div>
<div v-if="isOpen(keys[index]!)" :id="`${controlId}-${keys[index]}-body`" data-repeater-body class="p-4">
<dl v-if="readOnly" class="m-0 grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
<PreviewField
v-for="item in nested"
:key="item.name"
:class="item.span === 'left' ? 'min-[600px]:col-start-1' : item.span === 'right' ? 'min-[600px]:col-start-2' : 'col-span-full'"
:field="{ ...item, readOnly: true }"
:value="row[item.name]"
:source="source"
:record-id="recordId"
:id-prefix="`${controlId}-${keys[index]}`"
/>
</dl>
<FormGrid
v-else
:fields="nested"
:values="row"
:errors="rowErrors[index] ?? {}"
:notices="rowNotices[index]"
:source="source"
:record-id="recordId"
:id-prefix="`${controlId}-${keys[index]}`"
:scope-path="field.name"
@update="(name: string, value: unknown) => onNested(index, name, value)"
/>
</div>
</div>
</template>
<div
v-if="dragging !== null && slot === rows.length && slot !== dragging + 1"
data-repeater-slot
class="h-12 rounded-inner border-2 border-dashed border-primary bg-sel"
@dragover.prevent
@drop="onDrop"
/>
<div v-if="!readOnly" class="flex flex-wrap items-center gap-4">
<Button
ref="addButton"
variant="outline"
size="sm"
:icon="Plus"
data-action="repeater-add"
:disabled="maxItems !== null && rows.length >= maxItems"
@click="add"
>
{{ addLabel }}
</Button>
<span v-if="maxItems !== null" data-repeater-count class="text-[13px] text-muted tabular-nums">
{{ t('backend::lang.repeater.count', { current: rows.length, max: maxItems }) }}
</span>
<Button v-if="rows.length > 1" variant="ghost" size="sm" class="ml-auto" data-action="repeater-toggle-all" @click="toggleAll">
{{ t(anyOpen ? 'backend::lang.repeater.collapse_all' : 'backend::lang.repeater.expand_all') }}
</Button>
</div>
</div>
</template>