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

@@ -68,6 +68,12 @@ export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
/** One save notice of a plugin field type: field, content locale and message. */
export type FieldNotice = Schemas['cabana.FieldNotice']
/** A dependent dropdown's options for one sibling value (D-08): applies false takes no value. */
export type AdminDependentOptionsResult = Schemas['cabana.AdminDependentOptionsResult']
/** One dependent option; depth indents a choice taken from a tree. */
export type AdminDependentOption = Schemas['cabana.AdminDependentOption']
/** A repeater's row form: the nested fields of one row (D-07). */
export type RepeaterForm = Schemas['cabana.RepeaterForm']
/** A registered toolbar action the admin may run, with its localized label. */
export type ToolbarAction = Schemas['cabana.ToolbarAction']
/** One node of a rendered partial: an allowlisted element or a text node. */

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { t, tc } from '../../app/i18n'
import { keyField } from './formState'
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
// Messages of keys that are not form fields are listed here, so no server
@@ -14,9 +15,11 @@ const props = withDefaults(
)
const count = computed(() => Object.keys(props.errors).length)
// A repeater row path (`content.3.answer`) belongs to its field: it renders
// under the nested control, not here.
const orphans = computed(() =>
Object.entries(props.errors)
.filter(([name]) => !props.fieldNames.includes(name))
.filter(([name]) => !props.fieldNames.includes(name) && !props.fieldNames.includes(keyField(name)))
.flatMap(([, messages]) => messages),
)
</script>

View File

@@ -1,12 +1,17 @@
<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.
defineProps<{
// 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[]>
@@ -16,9 +21,17 @@ defineProps<{
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) {

View File

@@ -21,7 +21,7 @@ const props = defineProps<{
}>()
/** Types rendered by their own control in its read-only mode. */
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor'])
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater'])
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
const labelId = computed(() => `${controlId.value}-label`)

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>

View File

@@ -5,7 +5,7 @@
// provider (settings pages) gets the defaults of the injecting control.
import { ref, type InjectionKey, type Ref } from 'vue'
import type { FileRoutes } from '../../api/files'
import type { AdminRecord } from '../../api/types'
import type { AdminDependentOptionsResult, AdminRecord, FormField } from '../../api/types'
/** Read-only view of the form's current values. */
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
@@ -113,3 +113,39 @@ export const FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'
/** How long a widget waits for its custom element to be defined. */
export const WIDGET_TIMEOUT = 5000
/**
* The record form's feedback (Phase 17, D-07, D-08): errors and save notices
* keyed by field name or row path (`<repeater>.<index>.<nested>`), the form's
* single polite live region, and its confirm dialog. FormView provides it; a
* control without it (settings pages) announces nothing and never asks.
*/
export interface FormFeedback {
errors: Readonly<Ref<Record<string, string[]>>>
notices: Readonly<Ref<Record<string, string[]>>>
/** Drops the error lines of one key: the administrator edited that value. */
clearError(key: string): void
/** Announces text in the form's polite live region. */
announce(text: string): void
/** Asks through the form's ConfirmDialog; resolves true on confirm. */
confirm(request: { message: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>
}
export const FORM_FEEDBACK: InjectionKey<FormFeedback> = Symbol('summer.form.feedback')
/**
* The fields a FormGrid renders and their values (D-08): a dependent
* dropdown reads its sibling here. A repeater row's grid provides the row's
* fields and values, and `path` names a field for its routes
* (`<repeater>.<field>` in a row).
*/
export interface FieldScope {
fields: Readonly<Ref<readonly FormField[]>>
values: Readonly<Ref<AdminRecord>>
path(name: string): string
}
export const FIELD_SCOPE: InjectionKey<FieldScope> = Symbol('summer.form.fieldScope')
/** Dependent options per field path and sibling value, for the life of a form view. */
export const DEPENDENT_CACHE: InjectionKey<Map<string, AdminDependentOptionsResult>> = Symbol('summer.form.dependentCache')

View File

@@ -296,3 +296,65 @@ export function focusField(prefix: string, name: string): boolean {
element.focus()
return true
}
/** The top-level field of an error or notice key: `content` for `content.3.answer`. */
export function keyField(key: string): string {
const dot = key.indexOf('.')
return dot < 0 ? key : key.slice(0, dot)
}
/** Whether a field has errors on itself or, for a repeater, on any row path. */
export function fieldHasErrors(errors: Record<string, string[]>, name: string): boolean {
return Object.keys(errors).some((key) => key === name || key.startsWith(`${name}.`))
}
/** The entries of one repeater row (`<field>.<index>.<nested>`) keyed by nested name. */
export function rowEntries(map: Record<string, string[]>, field: string, index: number): Record<string, string[]> {
const prefix = `${field}.${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
}
/** The first error key of a field in the server's order: itself, else its first row path. */
export function firstErrorKey(errors: Record<string, string[]>, name: string): string | undefined {
if (errors[name]) {
return name
}
return Object.keys(errors).find((key) => key.startsWith(`${name}.`))
}
const NAME = /^[A-Za-z0-9_-]+$/
/**
* Focuses the control of an error key. A row path (`content.3.answer`)
* focuses the nested control of that repeater row, found through the row's
* data attributes and the nested label's `for`; a plain name is focusField.
*/
export function focusError(prefix: string, key: string): boolean {
const parts = key.split('.')
if (parts.length < 3) {
return focusField(prefix, parts[0] ?? key)
}
const [field, index, nested] = parts as [string, string, string]
if (!NAME.test(field) || !/^\d+$/.test(index) || !NAME.test(nested)) {
return focusField(prefix, field)
}
const cell = document.querySelector(`[data-repeater="${field}"] [data-repeater-row="${index}"] [data-field="${nested}"]`)
const target = cell?.querySelector('label[for]')?.getAttribute('for')
const control =
(target ? document.getElementById(target) : null) ??
cell?.querySelector<HTMLElement>('input, select, textarea, [contenteditable="true"], button')
if (!control) {
return focusField(prefix, field)
}
if (!control.matches('input, select, textarea, button, [tabindex], [contenteditable="true"]')) {
control.setAttribute('tabindex', '-1')
}
control.focus()
return true
}

View File

@@ -24,7 +24,10 @@
// isRegisteredField, which know the field, not only its type.
// Phase 17 adds the richeditor control (D-10): an HTML string value edited
// with a lazily loaded TipTap editor, sent unchanged until the administrator
// edits it.
// edits it. The repeater control (D-07) holds a list of row objects, each
// a nested FormGrid, labelled as a group. It renders FormGrid, so it sits on
// an import cycle with this module; only this module imports it, so this
// module always evaluates first and the binding is complete here.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
@@ -37,6 +40,7 @@ import PasswordField from './fields/PasswordField.vue'
import MLPluginField from './fields/MLPluginField.vue'
import PluginField from './fields/PluginField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RepeaterField from './fields/RepeaterField.vue'
import RichEditorField from './fields/RichEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
@@ -84,6 +88,7 @@ const renderers = new Map<string, Component>([
['mltextarea', MLTextareaField],
['mlmarkdown', MLMarkdownField],
['richeditor', RichEditorField],
['repeater', RepeaterField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */
@@ -103,7 +108,16 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
* Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input.
*/
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown'])
const groupLabelledTypes = new Set<string>([
'widget',
'partial',
'fileupload',
'permissioneditor',
'mltext',
'mltextarea',
'mlmarkdown',
'repeater',
])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField

View File

@@ -13,7 +13,18 @@ import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import { FORM_ASSETS, FORM_ENABLED_LOCALES, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
import {
DEPENDENT_CACHE,
FORM_ASSETS,
FORM_ENABLED_LOCALES,
FORM_FEEDBACK,
FORM_LOCALE,
FORM_PATCH,
FORM_SESSION,
FORM_VALUES,
createUploadGate,
} from '../components/form/formContext'
import type { AdminDependentOptionsResult } from '../api/types'
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
@@ -22,7 +33,9 @@ import {
contextAllows,
editablePayload,
fieldErrors,
focusField,
fieldHasErrors,
firstErrorKey,
focusError,
initialValues,
isMLField,
mergeMLValue,
@@ -124,7 +137,7 @@ const tabs = computed<TabItem[]>(() => {
out.push(tab)
}
}
if (errors.value[field.name]) {
if (fieldHasErrors(errors.value, field.name)) {
tab.errors += 1
}
}
@@ -281,6 +294,36 @@ provide(FORM_PATCH, update)
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
provide(FORM_ASSETS, () => assetsReady)
// The form's single polite live region (dependent dropdown clears, repeater
// moves) and the feedback nested controls read: errors and notices by row
// path, the confirm dialog (Phase 17, D-07, D-08).
const announcement = ref('')
let announceTimer: ReturnType<typeof setTimeout> | null = null
function announce(text: string): void {
// Emptied first, so the same text is announced again.
announcement.value = ''
if (announceTimer !== null) {
clearTimeout(announceTimer)
}
announceTimer = setTimeout(() => {
announceTimer = null
announcement.value = text
}, 50)
}
provide(FORM_FEEDBACK, {
errors: computed(() => errors.value),
notices: computed(() => notices.value),
clearError: (key: string) => {
if (errors.value[key]) {
const next = { ...errors.value }
delete next[key]
errors.value = next
}
},
announce,
confirm: (request) => confirm.ask(request),
})
provide(DEPENDENT_CACHE, new Map<string, AdminDependentOptionsResult>())
provide(FORM_SESSION, {
key: sessionKey,
recordId: recordId ?? 0,
@@ -297,7 +340,8 @@ provide(FORM_SESSION, {
/** 422: messages under fields, the first invalid field (schema order) focused. */
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
errors.value = fieldErrors(error?.details)
const invalid = fields.value.filter((field) => errors.value[field.name])
// A repeater row path (content.3.answer) counts for its field.
const invalid = fields.value.filter((field) => fieldHasErrors(errors.value, field.name))
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
if (!first) {
return
@@ -306,7 +350,12 @@ async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<vo
activeTab.value = tabOf(first)
}
await nextTick()
focusField(ID_PREFIX, first.name)
const key = firstErrorKey(errors.value, first.name) ?? first.name
if (!focusError(ID_PREFIX, key)) {
// A repeater row the errors opened mounts its body one tick later.
await nextTick()
focusError(ID_PREFIX, key)
}
}
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
@@ -467,6 +516,9 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener(ML_LOCALE_CHANGE, onMLLocale)
if (announceTimer !== null) {
clearTimeout(announceTimer)
}
})
// The header's last breadcrumb is this record's title (design: Top header).
@@ -547,6 +599,8 @@ void load()
</div>
</footer>
<p class="sr-only" aria-live="polite" data-form-live>{{ announcement }}</p>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"