Skip to content

Describing fields ​

Shared types ​

ts
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.

ts
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.

ts
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:

ts
// 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 ​

ts
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),
  },
];

Released under the MIT License.