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

@@ -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 ?? ''"