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.
FieldDescriptionfor schema-driven field definitionsFieldRendererPropsas the shared renderer contractFieldTypeMapfor module augmentation and custom field typingfieldRegistryas the shared runtime registry instance, plus theFieldRegistryclass for isolated (scoped) registries- Layout config types (
LayoutConfig,BaseLayout,ResponsiveLayout,ColumnLayoutConfig,RowLayoutConfig,GridLayoutConfig) - the single source of truth re-exported by every adapter applyComputedValuesto resolvecomputeValuefields against form datavalidateField/validateFieldAsync,validateFields/validateFieldsAsync,resolveDisabled,resolveReadOnly,resolveOptionsand theValidationResult/ValidationContexttypes for opt-in, app-supplied validation and dynamic disabled/readOnly/options conditionscollectFieldPathsto expand a schema into the leaf paths that exist in the data (contacts[0].email), andindexGroupPathMapto index an error or touched map by group itemisFieldGroup,createGroupItem,canAddGroupItem,canRemoveGroupItemto work with repeatable field groups (FieldDescription.fields), plusmoveGroupItem,swapGroupItems,insertGroupItemandfocusFirstInvalidFieldfor driving a group's array yourselfzodValidator,yupValidator,valibotValidator/standardSchemaValidatorto validate with an existing schema libraryfieldsFromJsonSchemato build the field list itself from a JSON Schema- A multi-step wizard state machine:
createWizardState,validateStep,canGoNext/canGoPrev,goNext/goPrev/goToStep,markStepCompleted/isStepCompleted
npm install @dynamic-field-kit/coreNote: 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/angularThe 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.
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.
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.
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.
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).
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,
},
];validators provides common validation helpers:
validators.required(message?)- Enforces non-empty valuevalidators.email(message?)- Enforces valid email patternvalidators.minLength(min, message?)- Enforces minimum string/array lengthvalidators.maxLength(max, message?)- Enforces maximum string/array lengthvalidators.min(minVal, message?)- Enforces minimum numeric valuevalidators.max(maxVal, message?)- Enforces maximum numeric valuevalidators.pattern(regex, message?)- Enforces regex pattern matchvalidators.matches(otherFieldName, message?)- Enforces equality with another field, for confirm-password / confirm-email. Skips empty values sorequiredowns that message, and compares withObject.isso twoNaNs matchvalidators.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.
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.
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.
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.
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 hookvalidateFieldsAsync 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.
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.
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.
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.
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 }.
appearWhenhides the field while the condition is false. A hidden field is not rendered, not validated and not incollectFieldPaths, exactly as withappearCondition. If a field has both,appearConditionwins andappearWhenis ignored, so a function passed throughoverridesreplaces a stored rule.requiredWhenmakes the field required while the condition holds: the renderer receivesrequired: true(andariaRequired), and validation reports therequiredmessage from the form's message catalog when the value is empty. Once a value is there, the field's ownvalidateruns as usual.required: truewins over it: a field markedrequiredkeeps relying on itsvalidatehook (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.
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…' } } },
);fieldsgoes toMultiFieldInput/useDynamicFormlike a hand-written list.defaultsholds the schema'sdefaultvalues, shaped like the form data. Pass it as the initial data.warningslists 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.
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.
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.
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-levelpasswordandfilefields, 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;
onErrorhears about it. - Values that JSON cannot represent (
Dateobjects,Files,undefined) do not survive a round trip. Dates stored as strings, as the built-indateinput produces, are fine.
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.
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.
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.
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);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.
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 readscontacts[0].address.street.serverErrorsFromalready answers in these paths. - Conditions.
appearCondition,disabledanddisabledConditionon the section take all of it out of validation. Inside it, a rule or condition receives the section's object asdataand the form asrootData. - 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.
fieldsFromJsonSchematurns a nested object into a section andfieldsToJsonSchemawrites a section as a nested object, with its ownpropertiesandrequired. - A key of its own. A section is marked by
section, not by a flag besidefields: an adapter that does not know sections then reports an unknown field type instead of rendering a list. When both keys are set,fieldswins.
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.
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'); // truecore 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);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),
},
];coreis runtime-shared across adapters.coredoes not ship UI components.- The registry stores framework-specific renderers, but the framework-specific typing belongs in each adapter package.
@dynamic-field-kit/react@dynamic-field-kit/vue@dynamic-field-kit/angular
MIT