Describing fields
Shared types
export interface FieldRendererProps<T = any> {
value?: T;
onValueChange?: (value: T) => void;
onBlur?: () => void;
label?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
touched?: boolean;
dirty?: boolean;
error?: string | string[];
options?: Record<string, any>[];
optionsStatus?: 'idle' | 'loading' | 'ready' | 'error';
optionsError?: unknown;
/** Not in FIELD_RENDERER_PROP_KEYS - a callback, like onValueChange. */
onOptionsQuery?: (query: string) => void;
className?: string;
description?: any;
id?: string;
ariaInvalid?: boolean;
ariaDescribedBy?: string;
ariaRequired?: boolean;
min?: number | string;
max?: number | string;
step?: number | string;
accept?: string;
multiple?: boolean;
}Each adapter feeds a renderer the same inbound props but reports changes with its framework's native idiom: React calls the onValueChange prop, Angular emits a valueChange (or onValueChange) EventEmitter, and Vue emits update:value.
The contract is enforced, not merely declared
Every prop above reaches the renderer on all three adapters. That is not a convention — the list lives in core as FIELD_RENDERER_PROP_KEYS, the bag is built once by buildFieldRendererProps, every adapter calls it, and scripts/check-renderer-prop-parity.js fails the build if one of them stops carrying a key across its own component boundary. Before 1.6 each adapter hand-wrote its own list and they drifted: React silently dropped placeholder, min, max, step, accept and multiple; Vue dropped required, id, dirty and the aria flags; Angular dropped touched, dirty and id. A renderer written for one framework could not be ported to another, which is the opposite of the point.
One deliberate deviation remains, and it is a framework constraint rather than drift: Vue delivers className as class. Forwarding className too would let it fall through to a renderer's root element, where Vue assigns el.className — an undefined value becomes '' and wipes whatever class the renderer set on itself.
ariaDescribedBy used to be the one prop no adapter filled in — it was hard-coded undefined, on the reasoning that pointing aria-describedby at an id that might not exist was worse than leaving it unset. That reasoning held, but it made focusFirstInvalidField (which selects [aria-invalid="true"]) silently do nothing for every consumer following the renderer recipe, since the recipe never mentioned forwarding the aria props either.
Since 1.7.0 it is makeErrorId(id) — `${id}-error` — whenever the field has an error, and undefined while it is valid. The default renderers render a node carrying that id, so the reference resolves for them. A custom renderer that forwards aria-describedby must put makeErrorId(id) on whatever element shows its message, or the reference dangles again.
import { buildFieldRendererProps, makeFieldId } from '@dynamic-field-kit/core';
// What every adapter's FieldInput does. Resolves disabled/readOnly/options,
// validates (skipping disabled fields), and sets the aria flags.
const props = buildFieldRendererProps({
fieldDescription: field,
data, // this field's own level
rootData, // the top-level form
id: makeFieldId(field, idPrefix),
touched,
dirty,
});makeFieldId(field, prefix) returns field.id when set, else `${prefix}-${field.name}`. Adapters pass a prefix unique to each MultiFieldInput instance, so two forms holding a field of the same name no longer emit duplicate DOM ids.
export interface FieldDescription<T extends FieldTypeKey = FieldTypeKey> {
name: string;
type: T;
// Pins this field's DOM id. Otherwise the id is the owning MultiFieldInput's
// instance prefix plus `name`.
id?: string;
label?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
// `rootData` is the top-level form data; `data` is this field's own level
// (the group item when nested in a repeatable group).
appearCondition?: (
data: Record<string, any>,
rootData?: Record<string, any>,
) => boolean;
computeValue?: (
data: Record<string, any>,
rootData?: Record<string, any>,
) => unknown;
options?: Record<string, any>[];
className?: string;
description?: any;
// Extra props forwarded verbatim to the renderer (e.g. acceptFile, maxLength).
props?: Record<string, any>;
// Repeatable field group (see "Repeatable field groups" below)
fields?: FieldDescription[];
defaultItem?: Record<string, any>;
// Item property used as the stable list key; falls back to the array index.
keyField?: string;
minItems?: number;
maxItems?: number;
addLabel?: string;
removeLabel?: string;
}The value types that show up throughout that shape, and in every signature in this README:
// Any form-data object. `data` and `rootData` are always this.
export type Properties = Record<string, unknown>;
// What `validators.*` helpers return: one message, or undefined when valid.
export type ValidatorFn = (
value: unknown,
data?: Properties,
rootData?: Properties,
) => string | undefined;
// What a `FieldDescription.validate` hook may return. The Promise arm is what
// makes a field async-only -- see "Sync vs async validation" below.
export type FieldValidatorResult =
string | string[] | undefined | Promise<string | string[] | undefined>;
export type FieldValidatorFunction = (
value: unknown,
data: Properties,
rootData?: Properties,
) => FieldValidatorResult;The schema adapters (zodValidator and friends) each return a FieldValidatorFunction, which is why their result drops straight into validate.
Example schema
import type { FieldDescription } from '@dynamic-field-kit/core';
const fields: FieldDescription[] = [
{ name: 'username', type: 'text', label: 'Username' },
{
name: 'age',
type: 'number',
label: 'Age',
appearCondition: (data) => Boolean(data.username),
},
];