Skip to content

Latest commit

 

History

History
1186 lines (956 loc) · 52.8 KB

README.md

File metadata and controls

1186 lines (956 loc) · 52.8 KB

@dynamic-field-kit/core

Core types and shared registries for dynamic-field-kit.

@dynamic-field-kit/core is intentionally framework-agnostic. It does not import React, Vue, or Angular types in its public API. Applications define field schemas in core, then register framework-specific renderers through an adapter package such as @dynamic-field-kit/react, @dynamic-field-kit/vue, or @dynamic-field-kit/angular.

Live demo: https://vannt-dev.github.io/dynamic-field-kit/ — the same schema rendered by React, Vue and Angular, including the wizard engine documented below.

What this package provides

  • FieldDescription for schema-driven field definitions
  • FieldRendererProps as the shared renderer contract
  • FieldTypeMap for module augmentation and custom field typing
  • fieldRegistry as the shared runtime registry instance, plus the FieldRegistry class for isolated (scoped) registries
  • Layout config types (LayoutConfig, BaseLayout, ResponsiveLayout, ColumnLayoutConfig, RowLayoutConfig, GridLayoutConfig) - the single source of truth re-exported by every adapter
  • applyComputedValues to resolve computeValue fields against form data
  • validateField / validateFieldAsync, validateFields / validateFieldsAsync, resolveDisabled, resolveReadOnly, resolveOptions and the ValidationResult / ValidationContext types for opt-in, app-supplied validation and dynamic disabled/readOnly/options conditions
  • collectFieldPaths to expand a schema into the leaf paths that exist in the data (contacts[0].email), and indexGroupPathMap to index an error or touched map by group item
  • isFieldGroup, createGroupItem, canAddGroupItem, canRemoveGroupItem to work with repeatable field groups (FieldDescription.fields), plus moveGroupItem, swapGroupItems, insertGroupItem and focusFirstInvalidField for driving a group's array yourself
  • zodValidator, yupValidator, valibotValidator / standardSchemaValidator to validate with an existing schema library
  • fieldsFromJsonSchema to build the field list itself from a JSON Schema
  • A multi-step wizard state machine: createWizardState, validateStep, canGoNext / canGoPrev, goNext / goPrev / goToStep, markStepCompleted / isStepCompleted

Install

npm install @dynamic-field-kit/core

Note: This package is framework-agnostic. To render forms in an application, install a framework adapter as well (for example, @dynamic-field-kit/react, @dynamic-field-kit/vue, or @dynamic-field-kit/angular).

  • Example: npm install @dynamic-field-kit/core @dynamic-field-kit/react

  • Or: npm install @dynamic-field-kit/core @dynamic-field-kit/vue

  • Or: npm install @dynamic-field-kit/core @dynamic-field-kit/angular

  • Core is intended to be a single source of truth for registry and types. Ensure your app uses a single version of @dynamic-field-kit/core to avoid multiple registry instances.

Install a UI adapter alongside it when rendering forms:

npm install @dynamic-field-kit/core @dynamic-field-kit/react
# or
npm install @dynamic-field-kit/core @dynamic-field-kit/vue
# or
npm install @dynamic-field-kit/core @dynamic-field-kit/angular

Core idea

The library does not hard-code field types like "text" | "number".

Instead, apps extend FieldTypeMap:

export interface FieldTypeMap {}

That keeps the package open for custom field types without modifying the library.

Define field types in your app

Create a declaration file such as src/types/dynamic-field-kit.d.ts:

import '@dynamic-field-kit/core';

declare module '@dynamic-field-kit/core' {
  interface FieldTypeMap {
    text: string;
    number: number;
    checkbox: boolean;
    select: string;
  }
}

Make sure that file is included by your app's tsconfig.json.

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;
  // The same rule as data, which survives JSON (see "Conditions as data")
  appearWhen?: FieldCondition;
  requiredWhen?: FieldCondition;
  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.

Derived fields with computeValue

computeValue derives a field's value from the rest of the form data (e.g. a fullName computed from firstName + lastName). It is called as (data, rootData) - rootData is the top-level form even for fields nested in a group. Every adapter's MultiFieldInput re-evaluates it once per change, against the post-change data - it is not re-run to a fixed point, so avoid chaining computeValue fields into a cycle, and return a primitive or a stable reference (returning a fresh object/array each call defeats the render-skipping optimisation). In development, applyComputedValues warns when a computeValue chain does not converge in one pass.

import { applyComputedValues } from '@dynamic-field-kit/core';

const fields: FieldDescription[] = [
  { name: 'firstName', type: 'text' },
  { name: 'lastName', type: 'text' },
  {
    name: 'fullName',
    type: 'text',
    computeValue: (data) =>
      `${data.firstName ?? ''} ${data.lastName ?? ''}`.trim(),
  },
];

applyComputedValues(fields, { firstName: 'Ada', lastName: 'Lovelace' });
// => { firstName: 'Ada', lastName: 'Lovelace', fullName: 'Ada Lovelace' }

Adapters call applyComputedValues for you whenever MultiFieldInput's data changes; you normally only need to import it directly when working with form data outside of a component (e.g. on submit).

Validation & conditions

validate, disabledCondition, and readOnlyCondition are app-supplied hooks on FieldDescription. @dynamic-field-kit/core includes a set of built-in helper validators as well as async validation functions (validateFieldsAsync).

import {
  validators,
  validateFields,
  validateFieldsAsync,
} from '@dynamic-field-kit/core';

const fields: FieldDescription[] = [
  {
    name: 'email',
    type: 'text',
    // Built-in composed validator
    validate: validators.compose(
      validators.required('Email is required'),
      validators.email('Must be a valid email address'),
    ),
    readOnlyCondition: (data, rootData) => (rootData ?? data).frozen === true,
  },
  {
    name: 'username',
    type: 'text',
    // Async validation (e.g. checking availability via API)
    validate: async (value) => {
      if (!value) return 'Username required';
      const available = await checkUsername(String(value));
      return available ? undefined : 'Username already taken';
    },
  },
  {
    name: 'city',
    type: 'select',
    // Dynamic options callback dependent on current form data
    options: (data) =>
      data.country === 'VN' ? ['Hanoi', 'HCM'] : ['NY', 'LA'],
    disabledCondition: (data) => !data.country,
  },
];

Built-in Validators Utility (validators)

validators provides common validation helpers:

  • validators.required(message?) - Enforces non-empty value
  • validators.email(message?) - Enforces valid email pattern
  • validators.minLength(min, message?) - Enforces minimum string/array length
  • validators.maxLength(max, message?) - Enforces maximum string/array length
  • validators.min(minVal, message?) - Enforces minimum numeric value
  • validators.max(maxVal, message?) - Enforces maximum numeric value
  • validators.pattern(regex, message?) - Enforces regex pattern match
  • validators.matches(otherFieldName, message?) - Enforces equality with another field, for confirm-password / confirm-email. Skips empty values so required owns that message, and compares with Object.is so two NaNs match
  • validators.compose(...fns) - Combines multiple validator functions into one

validateFields(fields, data, rootData?, context?) returns { valid, errors, complete, status }, recursing into repeatable groups (keys like contacts[0].email) and skipping fields that are hidden (by appearCondition or appearWhen) or disabled. Adapters call validateField / resolveDisabled / resolveReadOnly / resolveOptions per field to surface error, disabled, readOnly and the resolved options to renderers reactively.

Validation messages

Built-in validators resolve their message when they run, not when the field description is built, so a catalog set once for a form reaches all of them:

import {
  createMessageResolver,
  setDefaultMessages,
} from '@dynamic-field-kit/core';

// Per form, through an adapter:
useDynamicForm({ fields, messages: { required: 'Bắt buộc' } });

// Or process-wide, for direct validateFields callers:
setDefaultMessages({ required: 'Bắt buộc' });

// Or built by hand and passed as the validation context:
validateFields(fields, data, undefined, {
  t: createMessageResolver({ required: 'Bắt buộc' }),
});

Precedence is: a message passed straight to the validator, then the form's catalog, then the process-wide one, then the validator's English default.

Key Params English default
required — Field is required
email — Invalid email address
minLength {min} Minimum length is {min}
maxLength {max} Maximum length is {max}
min {min} Minimum value is {min}
max {max} Maximum value is {max}
pattern — Invalid format
matches {other} Must match {other}

No locale bundles ship with this package. Supply your own catalog. A placeholder with no matching param is left verbatim rather than replaced with undefined, so a typo surfaces as a visible {unit}.

ValidationContext — already validate's fourth argument, carrying signal — gains an optional t, so a hand-written validator can translate its own messages the same way.

Async options

options takes a static array, a synchronous (data, rootData) => Options[], or a loader returning a promise. It is one signature, not a union: a union of two function types defeats TypeScript's contextual inference, which would make every existing options: (data) => … an implicit-any error.

{
  name: 'city',
  type: 'select',
  options: async (data, _rootData, ctx) =>
    fetch(`/api/cities?country=${data.country}`, { signal: ctx?.signal })
      .then((r) => r.json()),
  optionsDeps: (data) => [data.country],
  debounceMs: 200,
}
Property Effect
optionsDeps Values a reload depends on, compared shallowly with Object.is. Default [] — fetch once
optionsMode 'async' for a loader returning a promise without the async keyword
debounceMs Collapses rapid reloads into one fetch. Async options only

Renderers receive optionsStatus ('idle' | 'loading' | 'ready' | 'error'), optionsError, and onOptionsQuery(query) for search-remote fields whose query the form data never sees.

createOptionsLoader(field, onChange) is the framework-agnostic engine the adapters wrap: it debounces, aborts a superseded request through ctx.signal, and discards a response that lands out of order, so the list always reflects the newest request rather than the last to arrive.

Native async functions are detected automatically. A loader wrapped in a memoiser, a spy or a transpiler helper is not — constructor.name stops being 'AsyncFunction'. Declare optionsMode: 'async' for those; without it the promise is dropped and a development warning says so, rather than the renderer receiving a pending promise as its option list.

Reading a ValidationResult

interface ValidationResult {
  valid: boolean;
  errors: Record<string, string[]>;
  /** Fields whose validator could not run synchronously. Omitted when empty. */
  pending?: string[];
  /** True only when every applicable validator finished in this pass. */
  complete: boolean;
  status: 'valid' | 'invalid' | 'pending';
}

Read status. valid on its own cannot tell "nothing is wrong" from "nothing is wrong yet": a form whose only remaining rule is a remote uniqueness check reports valid: true while that check has not run, which reads as a green light. status is 'pending' exactly when something is still unanswered, and complete says whether every applicable validator finished.

Sync vs async validation

validateField and validateFields are synchronous. An async validator is never invoked on that path - it is listed in result.pending instead, and the result comes back status: 'pending'. Async validators must go through the async pair for a final answer:

import {
  validateField,
  validateFieldAsync,
  validateFields,
  validateFieldsAsync,
} from '@dynamic-field-kit/core';

// One field. Both always return string[] (empty when valid).
const errors = validateField(field, value, data, rootData); // string[]
const errorsAsync = await validateFieldAsync(field, value, data, rootData);

// A whole schema. The sync result may also include `pending` field names.
const result = validateFields(fields, data); // sync hooks only
const resultAsync = await validateFieldsAsync(fields, data); // awaits each hook

validateFieldsAsync starts independent validators in parallel rather than awaiting them one after another, so a form with several remote rules costs the slowest one, not their sum.

The framework form helpers keep live validation synchronous, but their submit handlers automatically run an async-capable validation pass. They also expose validateAsync() for checks that must finish before submit.

Declaring an async validator

Detection covers a native async function. A function that returns a Promise without the async keyword is indistinguishable from a sync one until it has been called, so declare it:

const field: FieldDescription = {
  name: 'username',
  type: 'text',
  validationMode: 'async',
  validate: (value) => checkAvailability(value), // returns a Promise
};

With validationMode: 'async' the sync pass never invokes the validator - no wasted request per keystroke - and the dev-mode warning about a field live validation cannot check stays quiet, because you have said you know.

Cancelling a run

validateFieldsAsync(fields, data, rootData?, options?) takes a ValidationContext and hands its AbortSignal to every validator as a fourth argument:

const controller = new AbortController();
const result = await validateFieldsAsync(fields, data, data, {
  signal: controller.signal,
});

const field: FieldDescription = {
  name: 'username',
  type: 'text',
  validationMode: 'async',
  validate: (value, _data, _rootData, context) =>
    fetch(`/api/available?u=${value}`, { signal: context?.signal }).then((r) =>
      r.ok ? undefined : 'Already taken',
    ),
};

Once the signal aborts, remaining validators are skipped and the result comes back complete: false / status: 'pending' - a partial answer, never a false "valid". A validator that honours the signal by rejecting with an AbortError is treated the same way, so an aborted run does not reject the caller. Any other error still propagates.

The framework form helpers create and abort these controllers for you: typing cancels the live validation run in flight so a stale result cannot overwrite a newer one.

resolveOptions(field, data, rootData?) returns Properties[] | undefined, calling field.options when it is a callback and passing it through when it is a static array.

Schema adapters (Zod, Yup, Valibot / Standard Schema)

Attach a schema to a field's validate hook. By default the schema is treated as an object schema describing the whole form, and a field name selects which issues to surface:

import {
  zodValidator,
  yupValidator,
  valibotValidator,
} from '@dynamic-field-kit/core';

const schema = z.object({ email: z.string().email() });

const fields: FieldDescription[] = [
  { name: 'email', type: 'text', validate: zodValidator(schema, 'email') },
];

For a scalar schema covering a single value, say so explicitly:

validate: zodValidator(z.string().email(), { target: 'field' });

Both accept SchemaValidatorOptions — { field?: string; target?: 'form' | 'field' }. valibotValidator is an alias of standardSchemaValidator, which handles any Standard Schema object (including Zod's ~standard).

Adapters parse synchronously, so the result works with the synchronous validateFields (and therefore with the framework form hooks). A schema with async refinements or async .test() rules cannot be parsed synchronously — those return a Promise, so validate through validateFieldsAsync.

Conditions as data

appearCondition is a function, so a field list that uses it cannot be stored as JSON, sent from a server, or written to a JSON Schema. appearWhen and requiredWhen take the same kind of rule as plain data:

import type { FieldDescription } from '@dynamic-field-kit/core';

const fields: FieldDescription[] = [
  { name: 'country', type: 'select', options: countries },
  {
    name: 'state',
    type: 'text',
    appearWhen: { field: 'country', in: ['US', 'CA'] },
    requiredWhen: { field: 'country', equals: 'US' },
  },
  {
    name: 'vat',
    type: 'text',
    appearWhen: {
      all: [
        { field: 'accountType', equals: 'business' },
        { not: { field: 'country', equals: 'US' } },
      ],
    },
  },
];

// The same list, read back from storage, behaves the same.
const stored = JSON.parse(JSON.stringify(fields));
Condition True when
{ field, equals: value } the value equals value; arrays and objects compare by value, like JSON Schema
{ field, notEquals: value } it does not (a missing value is not equal to anything)
{ field, in: [a, b] } it equals one of the list
{ field, truthy: true } / false it is truthy / falsy in JavaScript terms
{ field, empty: true } / false it is / is not empty: undefined, null, '' or [], as for validators.required
{ all: [...] }, { any: [...] }, { not } every one, at least one, or not the one inside (all: [] is true, any: [] false)

field names a value at the field's own level: the item inside a repeatable group, the section's object inside a section. A dotted path reads deeper (address.country), and root: true reads the top-level form instead, the way rootData does for a function: { field: 'plan', equals: 'pro', root: true }.

  • appearWhen hides the field while the condition is false. A hidden field is not rendered, not validated and not in collectFieldPaths, exactly as with appearCondition. If a field has both, appearCondition wins and appearWhen is ignored, so a function passed through overrides replaces a stored rule.
  • requiredWhen makes the field required while the condition holds: the renderer receives required: true (and ariaRequired), and validation reports the required message from the form's message catalog when the value is empty. Once a value is there, the field's own validate runs as usual. required: true wins over it: a field marked required keeps relying on its validate hook (validators.required()), as it always has.

A condition that is not one of the shapes above is false, with a warning in development. evaluateCondition(condition, data, rootData?), resolveVisible and resolveRequired are exported for code outside a form. Every adapter reads both keys through core's resolveVisible, so an adapter from the release that introduced them needs core from that release too.

fieldsToJsonSchema writes both as if / then, and fieldsFromJsonSchema reads dependentRequired and simple if / then back into them; see Fields from a JSON Schema.

Fields from a JSON Schema

fieldsFromJsonSchema turns a JSON Schema object into a field list, so a form can be driven by the schema an API already publishes (an OpenAPI components.schemas entry, for example).

import { fieldsFromJsonSchema } from '@dynamic-field-kit/core';

const { fields, defaults, warnings } = fieldsFromJsonSchema(
  {
    type: 'object',
    required: ['email'],
    properties: {
      email: { type: 'string', format: 'email' },
      age: { type: 'integer', minimum: 18 },
      plan: { enum: ['free', 'pro'], default: 'free' },
      contacts: {
        type: 'array',
        items: {
          type: 'object',
          properties: { phone: { type: 'string' } },
        },
      },
    },
  },
  { overrides: { 'contacts[].phone': { placeholder: '+84…' } } },
);
  • fields goes to MultiFieldInput / useDynamicForm like a hand-written list.
  • defaults holds the schema's default values, shaped like the form data. Pass it as the initial data.
  • warnings lists every property or keyword that did not make it into the form, with its path. Nothing is dropped silently.
Schema Field
string text; format picks email, date, time, datetime-local (date-time) or password
number, integer number, with min / max from minimum / maximum and step from multipleOf (1 for an integer)
boolean checkbox
enum, or oneOf / anyOf made only of const select; a const member's title is the option label
array of enums select with multiple
array of objects repeatable group, with minItems / maxItems and a defaultItem from the item defaults
nested object section, with the defaults of its properties as the section's default

title becomes the label (a readable form of the property name otherwise), description the description, readOnly a readOnlyCondition. required, minLength, maxLength, pattern, minimum, maximum and the email format become validate hooks built from validators, so their messages go through the form's message catalog. Local $ref (#/...), allOf, and nullable types (type: ['string', 'null'], or anyOf with a null branch) are followed.

Conditional keywords become declarative conditions:

Schema Field
dependentRequired: { card: ['cvc'] } (or draft-07 dependencies) cvc gets requiredWhen: { field: 'card', empty: false }
if: { properties: { country: { const: 'US' } } }, then: { required: ['state'] } state gets requiredWhen: { field: 'country', equals: 'US' }
the same with else: { required: ['postcode'] } postcode gets requiredWhen: { field: 'country', notEquals: 'US' }
then: { properties: { vat: { type: 'string' } } }, vat not in properties a vat field with appearWhen set to the if

An if can test const, enum and presence (required) on one or more properties, nested ones included, combined with allOf, anyOf and not; several of them go in allOf. An if that names a property without listing it in required is read as if it did, since a form always has a value to compare.

Not turned into fields, and reported in warnings instead: objects without properties, tuples, arrays of free-form values, remote $ref, and exclusiveMinimum / exclusiveMaximum. So are an if that tests anything else (minimum, pattern...), a then that adds rules to a property that is always there, and other conditional keywords (dependentSchemas); express those with appearCondition through overrides.

overrides is keyed by the same path the warnings use and is merged over the generated field. Use it to choose an application-specific type (textarea, a custom picker), or to attach hooks a schema cannot express.

A JSON Schema from fields

fieldsToJsonSchema goes the other way: it describes the data a field list collects, for an API contract, a validator on the server, or documentation.

import { fieldsToJsonSchema } from '@dynamic-field-kit/core';

const { schema, warnings } = fieldsToJsonSchema(fields, {
  title: 'Sign-up',
  defaults: { plan: 'free' },
  types: { rating: { type: 'integer', minimum: 1, maximum: 5 } },
  overrides: { username: { minLength: 3, pattern: '^[a-z]+$' } },
});
Field Schema
text, textarea, search, tel, color, hidden string, with maxLength from props.maxLength
email, url, password, date, time, datetime-local string with the matching format (uri, date-time)
number, range number with minimum / maximum / multipleOf; integer when the step and minimum are whole
checkbox, switch boolean
select, radio, or any field with static options enum, or oneOf of const + title when a label differs from its value
the same with multiple array of that, with uniqueItems
a repeatable group (fields) array of objects, with minItems / maxItems and the defaultItem values as defaults
a section (section) nested object, with its own properties and required; its defaults go to its properties

label becomes title, a string description the description, required the required list, and a field with computeValue is readOnly. defaults takes form data and records it as default; strict: true adds additionalProperties: false.

requiredWhen and appearWhen are written as if / then in the object's allOf, which fieldsFromJsonSchema reads back to the same conditions:

{
  "type": "object",
  "properties": { "country": { "type": "string" }, "state": true },
  "allOf": [
    {
      "if": {
        "required": ["country"],
        "properties": { "country": { "enum": ["US", "CA"] } }
      },
      "then": { "properties": { "state": { "type": "string" } } }
    }
  ]
}

A field with appearWhen is defined under then, so its rules apply only while it is shown, and true holds its place in properties (and lets it through strict). A hidden field is not validated, so whatever value it still holds is allowed. A condition that reads the form root (root: true) from inside a group or section cannot be written on the nested object's schema, and is reported in warnings.

A schema cannot hold a function, so validate, appearCondition, readOnlyCondition and option loaders are listed in warnings rather than guessed at; put the rules they enforce in overrides, keyed by the same paths (username, contacts[].phone). A field type the kit does not know accepts any value until types describes it, and a file field is left out.

Multi-step wizard

A framework-agnostic state machine over grouped fields. State is immutable: every navigation returns a new object.

import {
  createWizardState,
  validateStep,
  goNext,
  goPrev,
  isStepCompleted,
} from '@dynamic-field-kit/core';

let wizard = createWizardState([
  { id: 'account', title: 'Account', fields: accountFields },
  { id: 'profile', title: 'Profile', fields: profileFields },
]);

const { valid, errors } = validateStep(wizard.currentStep, data);
if (valid) {
  wizard = goNext(wizard); // records the step it leaves in completedSteps
}
Export Description
createWizardState(steps, index?) Initial state; index is clamped into range
validateStep(step, data) { valid, errors } for one step's fields
canGoNext / canGoPrev Whether a move is possible
goNext / goPrev Move one step; returns the same object when it cannot, so === detects a no-op
goToStep(state, index) Jump anywhere (clamped); does not mark anything completed
markStepCompleted(state, index?) Record a step as done, defaulting to the current one
isStepCompleted(state, index) For rendering a step indicator

Each step is a FormStep, and WizardState is what every helper above takes and returns:

export interface FormStep {
  id: string;
  title: string;
  description?: string;
  fields: FieldDescription[];
}

export interface WizardState {
  currentStepIndex: number;
  totalSteps: number;
  isFirstStep: boolean;
  isLastStep: boolean;
  currentStep: FormStep;
  steps: FormStep[];
  completedSteps: number[];
}

goNext does not validate — call validateStep yourself so a "save draft and come back" flow stays possible.

Saving a draft

createFormDraft keeps a form's data in storage between visits, so a reload or a closed tab does not lose what was typed. It is not tied to an adapter: load the draft into the initial values, save whenever the data changes, clear after a submit.

import { createFormDraft, draftExclusions } from '@dynamic-field-kit/core';
import { useDynamicForm } from '@dynamic-field-kit/react';

const draft = createFormDraft({
  key: 'signup-form',
  version: 1, // bump when the fields change shape
  maxAgeMs: 7 * 24 * 60 * 60 * 1000,
  exclude: draftExclusions(fields), // passwords and file inputs
});

function SignupForm() {
  const form = useDynamicForm({
    fields,
    initialValues: draft.load() ?? { plan: 'free' },
  });

  useEffect(() => draft.save(form.data), [form.data]);
  useEffect(() => () => draft.flush(), []); // write what is pending on unmount

  const submit = form.handleSubmit(async (data) => {
    await api.signUp(data);
    draft.clear();
  });
  // …
}

The same four calls work with the Vue composable (watch(() => form.data, draft.save, { deep: true })) and the Angular signal store (effect(() => draft.save(store.data()))).

Option Meaning
key Storage key. Use one per form, and per record when editing existing data.
storage Anything with getItem / setItem / removeItem. Defaults to localStorage; pass sessionStorage for a draft that ends with the tab.
version A draft saved under another version is discarded, not loaded into fields that no longer match it.
debounceMs Writes wait this long after the last save (default 300). 0 writes at once.
maxAgeMs A draft older than this is discarded on load.
exclude Top-level field names that are never written.
onError Told when storage refuses a read or a write.

load() returns undefined when there is no usable draft, flush() writes a pending save now, clear() removes the draft and savedAt() gives the time of the last write, for a "draft restored from …" notice.

Things to know:

  • The draft is plain JSON in the browser's storage. Do not keep secrets in it: draftExclusions(fields) lists the top-level password and file fields, and you can add your own names. Fields nested inside a repeatable group are not filtered.
  • Storage can fail (private browsing, a full quota, a sandboxed frame, server rendering). The draft then does nothing rather than throw; onError hears about it.
  • Values that JSON cannot represent (Date objects, Files, undefined) do not survive a round trip. Dates stored as strings, as the built-in date input produces, are fine.

Undo and redo

createFormHistory gives a form undo and redo. Like the draft, it is not tied to an adapter: push the data whenever it changes and put what undo or redo returns back into the form.

import { createFormHistory } from '@dynamic-field-kit/core';
import { useDynamicForm } from '@dynamic-field-kit/react';

function ProfileForm() {
  const form = useDynamicForm({ fields, initialValues });
  const [history] = useState(() => createFormHistory(form.data));

  useEffect(() => history.push(form.data), [form.data]);

  const undo = () => {
    const data = history.undo();
    if (data) form.setData(data);
  };
  const redo = () => {
    const data = history.redo();
    if (data) form.setData(data);
  };
  // <button disabled={!history.canUndo()} onClick={undo}>Undo</button>
}
Option Meaning
limit How many undo steps are kept (default 100); the oldest go first.
coalesceMs Pushes that change the same top-level fields within this window become one step (default 500). 0 turns it off.

canUndo() and canRedo() say whether there is a step to go to, current() returns the data at the current step and reset(data) forgets the history, for example after a submit or after loading a draft.

Things to know:

  • Pushing the data current() already holds is ignored. Restoring a step makes the form report that data back, and that report does not become a new step or wipe the redo steps.
  • Typing is grouped. Typing a word into one field is undone in one go; a pause longer than coalesceMs, a change to another field, or an undo starts a new step.
  • Steps are kept by reference. Plain objects and arrays are compared by content; other values (Date, File) by identity. Replace form data instead of mutating it, as the adapters already do.

Input masks

A field's mask shows its value formatted while it is typed; the form keeps the unformatted value.

import { createInputMask } from '@dynamic-field-kit/core';
import type { FieldDescription, InputMask } from '@dynamic-field-kit/core';

const phone: FieldDescription = {
  name: 'phone',
  type: 'text',
  mask: '(999) 999-9999',
};

// The same mask outside a form, for a table cell or a summary.
const mask = createInputMask('(999) 999-9999');
mask.format('5551234567'); // '(555) 123-4567'
mask.format('555'); // '(555'
mask.parse('(555) 123-4567'); // '5551234567'

// What a pattern cannot say: a pair of functions.
const upper: InputMask = {
  format: (value) => String(value ?? ''),
  parse: (text) => text.toUpperCase(),
};

Patterns. 9 is a digit, a a letter (of any alphabet), * either. Any other character is fixed, and a backslash makes the next character fixed ('\\9'). The stored value is the characters typed into those places, without the fixed ones.

What is shown. A fixed character is shown once the character after it exists, and the ones that close the pattern once every place is filled, so the text never ends in a separator that Backspace would have to fight. A stored value that does not fit is shown as far as it does: characters that fit no place are left out, and so is what is past the last place.

What is stored. parse(text, previous) reads the text of the input back. A character that does not fit its place is dropped; fixed characters are matched, not stored (the 1 of '+1 (999) 999-9999' is never taken for a typed digit). previous is the value the form held while the text was edited. With it, a deleted separator is told apart from text that was merely re-typed, and the typed character before the separator is removed, as Backspace means to. A { format, parse } pair receives the same second argument.

Validation runs on the stored value. A pattern does not make a partial value invalid by itself: validators.minLength(10) or validators.pattern says when the value is whole.

The caret. The kit rewrites the text and leaves the caret to the browser: typing and deleting at the end behave as expected, and an edit in the middle that makes the mask rewrite the text sends the caret to the end.

Renderers need no change. They receive the formatted text as value and report the edited text through onValueChange. A renderer has to show value whenever it is handed one, as a controlled input does.

Adapters. buildFieldRendererProps applies a mask only when asked to, with applyMask: true. It then returns the formatted text as value and a parseInput function, which the adapter keeps for itself and puts between the renderer and the form: onChange(parseInput ? parseInput(input) : input). Without applyMask the field is built as if it had no mask, which is what an adapter that knows nothing of masks needs: it would show the formatted text and then store that text as the value.

Errors from the server

serverErrorsFrom(body, options?) turns the answer of a backend that refused a submit into errors by field path, ready for a form store's setServerErrors:

import {
  collectFieldPaths,
  FORM_ERROR_KEY,
  serverErrorsFrom,
} from '@dynamic-field-kit/core';

serverErrorsFrom(
  { errors: { Email: ['Already registered'] } },
  { paths: collectFieldPaths(fields, data) },
);
// { email: ['Already registered'] }

serverErrorsFrom({
  errors: [
    { pointer: '/contacts/1/phone', detail: 'Not a phone number' },
    { message: 'The order is closed' },
  ],
});
// { 'contacts[1].phone': ['Not a phone number'], [FORM_ERROR_KEY]: ['The order is closed'] }

It reads a map of field to message or messages; that map under errors or fieldErrors (with formErrors beside it); and a list under errors or issues whose items name their field as field, path, name, property, param, pointer or source.pointer and carry a message, detail, msg or constraints. A JSON Pointer, a dotted path, a JSONPath and a list of segments all become the path the kit uses (contacts[1].phone). With paths, a key that differs from a form field only by case takes that field's spelling. Errors that name no field are kept under FORM_ERROR_KEY. A body that is not a set of field errors answers an empty object.

Group array helpers

Every adapter's MultiFieldInput renders add/remove controls itself. These helpers are for driving a group's array yourself — a drag handle, a "duplicate row" button, a custom group renderer. All are pure and never mutate the input:

import {
  moveGroupItem,
  swapGroupItems,
  insertGroupItem,
  isFieldGroup,
  createGroupItem,
  canAddGroupItem,
  canRemoveGroupItem,
  focusFirstInvalidField,
} from '@dynamic-field-kit/core';

const reordered = moveGroupItem(items, 3, 0); // same array back if out of range
const withRow = insertGroupItem(items, 1, createGroupItem(field));

if (canAddGroupItem(field, items)) {
  /* respects maxItems */
}

// After a failed submit: focus + scroll to the first [aria-invalid="true"] field
focusFirstInvalidField(formElement);

Repeatable field groups

A FieldDescription with fields becomes a repeatable group instead of a registry-rendered leaf field: data[name] becomes an array of items, each shaped by the nested fields. Every adapter's MultiFieldInput renders one nested form per item plus "Add"/"Remove" controls automatically - no adapter-specific wiring required.

const fields: FieldDescription[] = [
  {
    name: 'contacts',
    type: 'group', // any type key works; only `fields` matters for grouping
    label: 'Contacts',
    fields: [
      { name: 'email', type: 'text', label: 'Email' },
      { name: 'phone', type: 'text', label: 'Phone' },
    ],
    defaultItem: { email: '', phone: '' }, // seed values for a new item
    minItems: 1,
    maxItems: 5,
  },
];

Set keyField to an item property (e.g. an id) so each item keeps a stable list identity; without it the array index is used, which reassociates item state when an item is reordered or removed from the middle.

The isFieldGroup, createGroupItem, canAddGroupItem, and canRemoveGroupItem helpers back this feature and are exported for adapters (or apps) that need to replicate the same add/remove bounds logic outside of MultiFieldInput.

Sections

A FieldDescription with section is a titled section instead of a registry-rendered leaf field: data[name] is one object shaped by the nested fields. It is the non-repeating sibling of a group, and every adapter's MultiFieldInput renders it as a <fieldset> with label as its <legend>.

const fields: FieldDescription[] = [
  {
    name: 'address',
    type: 'section', // any type key works; only `section` matters
    label: 'Ship to',
    section: [
      { name: 'street', type: 'text', validate: validators.required() },
      { name: 'city', type: 'text' },
    ],
  },
];

validateFields(fields, { address: { city: 'Hue' } }).errors;
// { 'address.street': ['Field is required'] }
collectFieldPaths(fields, {});
// ['address.street', 'address.city']
  • Paths. Errors, pending rules and touched flags of the fields inside are keyed ${name}.${childName}; inside a group item that reads contacts[0].address.street. serverErrorsFrom already answers in these paths.
  • Conditions. appearCondition, disabled and disabledCondition on the section take all of it out of validation. Inside it, a rule or condition receives the section's object as data and the form as rootData.
  • Data that is not there yet. A section whose value is missing, or is not an object, is read as an empty one.
  • JSON Schema. fieldsFromJsonSchema turns a nested object into a section and fieldsToJsonSchema writes a section as a nested object, with its own properties and required.
  • A key of its own. A section is marked by section, not by a flag beside fields: an adapter that does not know sections then reports an unknown field type instead of rendering a list. When both keys are set, fields wins.

isFieldSection tells a section from the rest, and sectionPathMap(map, name) cuts an error or touched map down to one section, keyed from inside it - what an adapter hands the nested form.

Sections need core and the adapter at 1.11 or newer. With an older core the adapter still renders the section, but the form's validation does not look inside it.

Scoped registries

fieldRegistry is a process-wide singleton shared by every adapter. To give part of an app its own renderers, construct an isolated FieldRegistry and provide it with your framework's mechanism (React FieldRegistryProvider, Vue provideFieldRegistry, Angular FIELD_REGISTRY token). Code that never provides one keeps using the global singleton.

import { FieldRegistry } from '@dynamic-field-kit/core';

const registry = new FieldRegistry();
registry.register('text', myRenderer);

registry.has('text'); // true
registry.list(); // ['text']
registry.unregister('text'); // true

Register renderers through an adapter

core owns the shared registry instance, but renderer registration should happen through the framework adapter so the adapter can expose the correct renderer type for that framework.

Typical adapter imports:

import { fieldRegistry as reactRegistry } from '@dynamic-field-kit/react';
import { fieldRegistry as vueRegistry } from '@dynamic-field-kit/vue';
import { fieldRegistry as angularRegistry } from '@dynamic-field-kit/angular';

Then register a renderer using the adapter that matches your UI framework.

React:

import { fieldRegistry } from '@dynamic-field-kit/react';

fieldRegistry.register('text', ({ value, onValueChange, label }) => (
  <label>
    <span>{label}</span>
    <input
      value={value ?? ''}
      onChange={(e) => onValueChange?.(e.target.value)}
    />
  </label>
));

Vue:

import { defineComponent, h } from 'vue';
import { fieldRegistry } from '@dynamic-field-kit/vue';

fieldRegistry.register(
  'text',
  defineComponent({
    setup() {
      return () => h('input');
    },
  }),
);

Angular:

import { fieldRegistry } from '@dynamic-field-kit/angular';
import { TextFieldComponent } from './text-field.component';

fieldRegistry.register('text', TextFieldComponent as any);

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

Notes

  • core is runtime-shared across adapters.
  • core does not ship UI components.
  • The registry stores framework-specific renderers, but the framework-specific typing belongs in each adapter package.

Related packages

  • @dynamic-field-kit/react
  • @dynamic-field-kit/vue
  • @dynamic-field-kit/angular

License

MIT