Files
summercms/admin/src/components/form/FormGrid.vue
Jakub Zych d3bf5d8f86 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
2026-10-12 16:08:23 +02:00

72 lines
2.4 KiB
Vue

<script setup lang="ts">
import { computed, provide } from 'vue'
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
import FormField from './FormField.vue'
import { FIELD_SCOPE } from './formContext'
import { RELATION_MANAGER } from './registry'
// Two-column form grid (design screen 4): span left -> column 1, right ->
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
// The grid is the scope its fields read siblings from (a dependent dropdown,
// D-08); a repeater row's grid passes `scopePath`, the repeater's name, so a
// nested field's routes address it as `<repeater>.<field>`.
const props = defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record<string, string[]>
/** Save notices per field (meta.notices as noticeLines); none when absent. */
notices?: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
recordId?: number | null
idPrefix?: string
/** The repeater whose row this grid renders. */
scopePath?: string
}>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
provide(FIELD_SCOPE, {
fields: computed(() => props.fields),
values: computed(() => props.values),
path: (name: string) => (props.scopePath ? `${props.scopePath}.${name}` : name),
})
function spanClass(field: FormFieldSchema): string {
// The relation manager always takes the whole row (design screen 5).
if (field.type === RELATION_MANAGER) {
return 'col-span-full'
}
switch (field.span) {
case 'left':
return 'min-[600px]:col-start-1'
case 'right':
return 'min-[600px]:col-start-2'
case 'auto':
case 'row':
return ''
default:
return 'col-span-full'
}
}
</script>
<template>
<div class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
<FormField
v-for="field in fields"
:key="field.name"
:class="spanClass(field)"
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:notices="notices?.[field.name]"
:labels="labels?.[field.name]"
:source="source"
:record-id="recordId"
:id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)"
/>
</div>
</template>