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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user