- The SPA loads the backend::lang bundle before /auth/me, sets the document language from meta.locale and renders plural messages with Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME - Create and record routes; mapWinterUrl maps recordUrl and redirects onto the controller's list, create and record routes only - List rows open their record; FormView loads the form schema and the record, shows context-allowed fields in the span grid, saves values keyed by field name and toasts the resolved saved message - A 422 puts each message under its field (aria-invalid, aria-describedby), shows the plural banner, focuses the first invalid field in schema order and clears a field's error on change - The D-05 registry maps text, textarea, number and dropdown; any other type renders the unsupported-field box with the type in DM Mono - The admin OpenAPI document declares the write request bodies (AdminRecord, AdminIDsRequest) and the list filter query as a deepObject, so the typed client can send them - New backend::lang form.load_failed key; boardwalk/dist rebuilt
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
// Field renderer registry (D-05). A form field's `type` selects the control
|
|
// component; any type without a renderer gets UnsupportedField, the design's
|
|
// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
|
|
// seam into the plugin extension point.
|
|
import type { Component } from 'vue'
|
|
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
|
import DropdownField from './fields/DropdownField.vue'
|
|
import NumberField from './fields/NumberField.vue'
|
|
import TextField from './fields/TextField.vue'
|
|
import TextareaField from './fields/TextareaField.vue'
|
|
import UnsupportedField from './fields/UnsupportedField.vue'
|
|
|
|
/** Props every field control receives from FieldRenderer. */
|
|
export interface FieldControlProps {
|
|
field: FormField
|
|
modelValue: unknown
|
|
/** id of the focusable control; the label points at it. */
|
|
controlId: string
|
|
invalid?: boolean
|
|
/** Space-separated ids of the comment and error lines. */
|
|
describedBy?: string
|
|
/** Display labels of the field's current relation values (meta.labels). */
|
|
labels?: RelationOption[]
|
|
/** Controller whose option endpoints serve this form; null for settings. */
|
|
source?: ControllerParams | null
|
|
}
|
|
|
|
const renderers = new Map<string, Component>([
|
|
['text', TextField],
|
|
['textarea', TextareaField],
|
|
['number', NumberField],
|
|
['dropdown', DropdownField],
|
|
])
|
|
|
|
/** Types whose control shows the label itself (toggle cards). */
|
|
const selfLabelled = new Set<string>()
|
|
|
|
export function rendererFor(type: string): Component {
|
|
return renderers.get(type) ?? UnsupportedField
|
|
}
|
|
|
|
/** Whether the SPA can edit values of this field type. */
|
|
export function isRegistered(type: string): boolean {
|
|
return renderers.has(type)
|
|
}
|
|
|
|
export function ownsLabel(type: string): boolean {
|
|
return selfLabelled.has(type)
|
|
}
|
|
|
|
// Winter `attributes:` are HTML attributes on the control. Only these are
|
|
// passed through; anything else (event handlers included) is dropped.
|
|
const allowedAttributes = new Set([
|
|
'readonly',
|
|
'placeholder',
|
|
'maxlength',
|
|
'minlength',
|
|
'min',
|
|
'max',
|
|
'step',
|
|
'autocomplete',
|
|
'spellcheck',
|
|
'pattern',
|
|
])
|
|
|
|
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
|
|
const out: Record<string, string | number | boolean> = {}
|
|
for (const [name, value] of Object.entries(field.attributes ?? {})) {
|
|
const key = name.toLowerCase()
|
|
if (!allowedAttributes.has(key) || value === null || value === false) {
|
|
continue
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Shared control styling: 44px, radius 10, danger border when invalid. */
|
|
export function controlClass(invalid: boolean | undefined): string {
|
|
return [
|
|
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
|
|
invalid ? 'border-danger' : 'border-border-strong',
|
|
].join(' ')
|
|
}
|