Skip to content

Latest commit

ย 

History

131 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Dynamic Field Kit

CI npm npm npm npm core downloads react downloads vue downloads angular downloads

๐Ÿ“˜ Handbook โ€” guides for every feature, per framework, with search. Also in Tiแบฟng Viแป‡t.

โ–ถ Live demos โ€” the same schema rendered by React, Vue, Angular and Svelte, including a multi-step wizard.

A lightweight, extensible dynamic form engine for React, Angular, Vue, and Svelte, built for scalable applications and design systems.

dynamic-field-kit lets you define forms using configuration objects instead of hard-coded UI, and allows applications to freely extend field types across frameworks without modifying the library. Register custom renderers in React, Angular, or vanilla JS using a shared field registry.


โœจ Features

  • Schema-driven dynamic forms
  • Extensible field types (no enums, no hard-coded unions)
  • Pluggable field renderers via shared registry
  • Runtime conditional fields (appearCondition)
  • Derived/computed fields (computeValue)
  • Repeatable field groups (fields) - "add another item" without leaving the schema
  • Titled sections (section) - a nested object under a heading, validated by path
  • Responsive layouts (mobile/desktop) with custom breakpoints
  • Clean TypeScript declarations (DTS-safe)
  • Framework-agnostic core (works with React, Angular, Vue, Svelte, or vanilla JS)
  • Ideal for form builders & design systems

๐Ÿš€ Enterprise Features (v1.5+)

  • Form State Hook / Composable / Signal Store: useDynamicForm for React & Vue 3, createDynamicForm for Svelte 5, createDynamicFormStore for Angular Signals. All of them expose the same surface โ€” including isSubmitting / isSubmitted โ€” and handleSubmit(onValid, onInvalid) returns a submit handler in every framework.
  • Extended HTML5 Renderers: Built-in support for radio, range, file, date, time, datetime-local, and switch.
  • Fields from JSON Schema: fieldsFromJsonSchema(schema) builds the field list, the default values and the validators from a JSON Schema object, and reports what it could not map. See the core README.
  • Schema Validation Adapters: Integrated zodValidator, yupValidator, valibotValidator, and Standard Schema adapters.
  • Multi-Step Form Wizard Engine: createWizardState, validateStep, canGoNext, canGoPrev, goNext, goPrev, goToStep, markStepCompleted, isStepCompleted. State is immutable โ€” every navigation returns a new state, and goNext records the step it leaves in completedSteps.
  • Draft Persistence: createFormDraft keeps form data in localStorage (or any storage) with debounced writes, a version and an age limit, and never writes the fields you exclude. See the core README.
  • Undo and Redo: createFormHistory records the form's data, groups typing into one step and ignores the data it just restored. See the core README.
  • Errors from the server: setServerErrors on every form store shows what a backend refused under the fields it names, until the user changes them; serverErrorsFrom(body) in core reads the usual response shapes into that map. See Errors from the server.
  • Interactive Form DevTools: Floating overlay component (<DynamicFormDevTools />) for realtime debugging.
  • Blur wiring: MultiFieldInput reports blur via onBlurField (an @Output in Angular), so a form store's handleBlur / touched / validateOnBlur can be connected to it. Pass touched back in to make the store its single source of truth.
  • Unique field ids: ids are namespaced per MultiFieldInput instance, so two forms holding a field of the same name do not collide. Override with idPrefix, or FieldDescription.id per field.
  • Group Array Manipulation Helpers: moveGroupItem, swapGroupItems, insertGroupItem, and focusFirstInvalidField.

Schema adapters

Attach an adapter 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 } from '@dynamic-field-kit/core';

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

const fields = [
  { 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' });

Adapters parse synchronously so their result works with validateFields (and therefore with useDynamicForm). A schema containing async refinements or async .test() rules cannot be parsed synchronously โ€” those return a Promise, so validate through validateFieldsAsync instead, and mark the field validationMode: 'async' so the live pass skips it rather than calling it on every keystroke.


๐Ÿ“ฆ Packages

Package Description
@dynamic-field-kit/core Core types and shared field registry
@dynamic-field-kit/react React components (FieldInput, MultiFieldInput, DynamicInput)
@dynamic-field-kit/angular Angular components and module (standalone + NgModule)
@dynamic-field-kit/vue Vue 3 components and module
@dynamic-field-kit/svelte Svelte 5 components and form state (createDynamicForm)

๐Ÿ“ฅ Installation

For React:

npm install @dynamic-field-kit/core @dynamic-field-kit/react

For Angular:

npm install @dynamic-field-kit/core @dynamic-field-kit/angular

For Vue:

npm install @dynamic-field-kit/core @dynamic-field-kit/vue

For Svelte 5:

npm install @dynamic-field-kit/core @dynamic-field-kit/svelte

@dynamic-field-kit/core is a peer dependency of every adapter (as is your framework: react + react-dom, vue, svelte, or @angular/*). Install it explicitly, as shown above โ€” the adapters no longer pull it in automatically. Keeping a single shared core version means all adapters resolve the same field registry.


๐Ÿงฑ Core Concepts

The library does NOT define field types like:

'text' | 'number';

Instead, it exposes an extendable interface that applications can augment:

export interface FieldTypeMap {}

This allows:

  • Unlimited custom field types
  • Strong typing without locking consumers
  • No need to rebuild the library

This pattern is used by mature libraries like MUI, React Hook Form, and Redux Toolkit.

๐Ÿงฉ Defining Field Types (App Side)

Create a .d.ts file in your app (e.g. src/types/dynamic-field.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 this file is included in tsconfig.json.


FieldRendererProps

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, unknown>[];
  className?: string;
  description?: unknown;
  id?: string;
  ariaInvalid?: boolean;
  ariaDescribedBy?: string;
  ariaRequired?: boolean;
  min?: number | string;
  max?: number | string;
  step?: number | string;
  accept?: string;
  multiple?: boolean;
}

๐Ÿ‘‰ A common contract for all field renderers โ€” and an enforced one. Core builds the bag in buildFieldRendererProps, every adapter calls it, and scripts/check-renderer-prop-parity.js fails the build if any of them stops forwarding a key. The single deliberate deviation is Vue's class in place of className (Vue assigns el.className on fallthrough, which would wipe the class a renderer sets on itself).

How a renderer reports a new value (per framework)

The value/props flow in is the same everywhere, but each adapter emits changes using its framework's native idiom. Write your renderer to the row for your framework:

Framework How the renderer emits a change Notes
React Call the onValueChange(value) prop Matches FieldRendererProps directly
Angular @Output() valueChange (or onValueChange) EventEmitter The adapter subscribes to either output
Vue emit('update:value', value) The standard v-model update event
Svelte Call the onValueChange(value) prop Matches FieldRendererProps directly

All of them receive the same inbound props (value, label, disabled, anything from FieldDescription.props, ...).


FieldDescription

A FieldDescription defines what a field is, not how it looks.

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

const fields: FieldDescription[] = [
  {
    name: 'username',
    type: 'text',
    label: 'Username',
  },
  {
    name: 'age',
    type: 'number',
    label: 'Age',
    appearCondition: (data) => data.username !== '',
  },
  {
    name: 'greeting',
    type: 'text',
    label: 'Greeting',
    computeValue: (data) => `Hello ${data.username || ''}`.trim(),
  },
];

Common Properties

Property Description
name Field key in form data
type Field renderer key
label UI label
value Default value
disabled Forwarded to the renderer as disabled
mask Shows the value formatted while it is typed and stores it unformatted: a pattern such as '(999) 999-9999' or a { format, parse } pair. See Input masks below
props Extra, framework-agnostic props forwarded verbatim to the renderer (e.g. acceptFile, maxLength) - keeps domain-specific inputs out of the adapter layer
appearCondition Runtime visibility condition. Called as (data, rootData): data is this field's own level (the group item, inside a repeatable group), rootData is always the top-level form data
computeValue Derives this field's value from the rest of the form data (e.g. a total or full name) whenever any field changes. Called as (data, rootData) like appearCondition. Evaluated once per change, not to a fixed point, so avoid chaining computeValue fields into a cycle, and return a primitive or a stable reference (a fresh object/array each call defeats render-skipping).

Repeatable Field Groups

A field 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. MultiFieldInput renders one nested form per item plus "Add"/"Remove" controls - no library-level wiring needed.

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,
  },
];
Property Description
fields Sub-fields rendered per item. Presence of fields is what marks this as a group.
defaultItem Values a newly-added item starts with. Defaults to {}.
keyField Property on each item to use as its stable list key (React key / Vue key / Angular trackBy). Falls back to the array index, which is unsafe if items can be reordered or removed from the middle.
minItems / maxItems Bounds enforced on the "Remove" / "Add" controls. Unbounded when omitted.
addLabel / removeLabel Custom button text (defaults to "Add" / "Remove").

Sections

A field with section is a titled section: data[name] is one object shaped by the nested fields, shown under label. It is the non-repeating sibling of a group - no "Add" or "Remove", one set of values.

const fields: FieldDescription[] = [
  { name: 'name', type: 'text', label: 'Name' },
  {
    name: 'address',
    type: 'section', // any type key works; only `section` matters
    label: 'Ship to',
    section: [
      { name: 'street', type: 'text', label: 'Street' },
      { name: 'city', type: 'text', label: 'City' },
    ],
  },
];
// data: { name: 'Ada', address: { street: '1 Le Loi', city: 'Hue' } }

MultiFieldInput renders it as a <fieldset> with the label as its <legend>, and className on the fieldset. Errors and touched flags of the fields inside are keyed by path (address.street), which is also the path setServerErrors takes. appearCondition hides the whole section, and disabled / disabledCondition disable every control in it and take it out of validation. Inside the section a condition receives the section's object as data and the form as rootData, as it does inside a group item. Sections nest in groups and in each other.

Like a group's array, the section's object is replaced on every edit inside it, so the section itself reads as dirty after the first one.

Input masks

mask shows a value formatted while it is typed and keeps the unformatted value in the form:

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

const fields: FieldDescription[] = [
  // Shown as (555) 123-4567, stored as '5551234567'.
  {
    name: 'phone',
    type: 'text',
    mask: '(999) 999-9999',
    validate: validators.minLength(10, 'Enter all ten digits'),
  },
  { name: 'expires', type: 'text', mask: '99/99' },
  // What a pattern cannot say: a pair of functions.
  {
    name: 'amount',
    type: 'text',
    mask: {
      format: (value) =>
        value === undefined || value === ''
          ? ''
          : Number(value).toLocaleString('en-US'),
      parse: (text) => {
        const digits = text.replace(/\D/g, '');
        return digits ? Number(digits) : undefined;
      },
    },
  },
];

In a pattern 9 is a digit, a a letter, * either, and any other character is fixed (\\9 in a string literal for a fixed 9). The form stores only what was typed into those places, so validators, computeValue, drafts and onSubmit all see '5551234567'. The renderer receives the formatted text as value and reports the edited text as it always did; nothing in it changes.

While typing, a separator appears together with the character after it, a character that does not fit its place is dropped, and Backspace on a separator removes the character before it. When an edit in the middle makes the mask rewrite the text, the caret goes to the end, as it does for any input whose text is rewritten under it. A mask is meant for renderers that show text: an <input type="number"> or type="date" refuses formatted text.

All four adapters apply it (from 1.11 on; an older adapter renders the field as if it had no mask). createInputMask(pattern) gives the same format and parse outside a form, for a table cell or a summary.

Validation & conditions

Fields can declare an app-supplied validate hook, built-in helper validators, async validation, plus dynamic disabledCondition / readOnlyCondition and dynamic options.

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('Invalid email'),
    ),
  },
  {
    name: 'city',
    type: 'select',
    // Dynamic options callback evaluated on form data change
    options: (data) =>
      data.country === 'VN' ? ['Hร  Nแป™i', 'HCM'] : ['NY', 'LA'],
    disabledCondition: (data) => !data.country,
  },
];
Property Description
validate `(value, data, rootData?, context?) => string
validationMode `'sync'
validators Built-in helpers: required, email, minLength, maxLength, min, max, pattern, matches, compose
options Array of option objects or dynamic callback function (data, rootData?) => Option[]
disabledCondition (data, rootData?) => boolean. OR-ed with the static disabled flag.
readOnlyCondition (data, rootData?) => boolean.

MultiFieldInput passes each field's current error and effective disabled/readOnly to its renderer (via FieldRendererProps), and emits an onValidityChange (validityChange in Angular) event with { valid, errors } on every change. Disabled and hidden (appearCondition) fields are skipped - they never produce errors. For submit-time or async validation of a whole form (including group items) call validateFields or validateFieldsAsync:

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

const { valid, errors, status } = validateFields(fields, data);
// errors: { "email": ["Required"], "contacts[1].email": ["Invalid"] }
// status: 'valid' | 'invalid' | 'pending'

// Async rules are never run by the sync pass - they come back as 'pending'.
// Await them for a final answer, optionally under an AbortSignal:
const controller = new AbortController();
const asyncResult = await validateFieldsAsync(fields, data, data, {
  signal: controller.signal,
});

Read status rather than valid alone: with a remote rule still unanswered, valid is true and means only "no synchronous rule failed". The form helpers surface the same distinction as isValidating, isValidationComplete and validationStatus, and cancel a superseded run for you.

Default Built-in HTML5 Renderers (Zero Config)

All framework adapters (react, vue, angular, svelte) ship with built-in HTML5 fallback renderers:

text ยท number ยท password ยท email ยท textarea ยท checkbox ยท select ยท radio ยท range ยท file ยท date ยท time ยท datetime-local ยท switch

If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states.

Every key above is also a FieldTypeMap entry, so type: 'switch' typechecks out of the box. The map itself is reachable if you need to wrap or inspect a default:

import {
  defaultRenderersMap,
  getDefaultRenderer,
} from '@dynamic-field-kit/react'; // or /vue

const Base = getDefaultRenderer('date'); // undefined for an unknown type
Object.keys(defaultRenderersMap); // every built-in type key

file emits a File (or File[] when multiple is set), range and number emit numbers, checkbox / switch emit booleans; everything else emits strings.

Custom Field Registry (Custom UI & Design Systems)

To use custom UI components (e.g., Tailwind, Ant Design, Shadcn UI), register custom renderers using fieldRegistry:

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

fieldRegistry.register('text', CustomInputComponent);
fieldRegistry.register('select', CustomSelectComponent);

The registry also exposes has(type), unregister(type), and list() for introspection and teardown.

Scoped Registries

fieldRegistry is a process-wide singleton, which is convenient but means every form shares one set of renderers. To give part of an app its own renderers (e.g. two design systems side by side), create an isolated FieldRegistry and provide it with your framework's native mechanism - existing code that never provides one keeps using the global singleton:

// React
import { FieldRegistry, FieldRegistryProvider } from '@dynamic-field-kit/react';
const registry = new FieldRegistry();
registry.register('text', myTextRenderer);

<FieldRegistryProvider registry={registry}>
  <MultiFieldInput fieldDescriptions={fields} />
</FieldRegistryProvider>;
// Vue: provideFieldRegistry(registry) in a parent's setup()
// Angular: { provide: FIELD_REGISTRY, useValue: registry } in a component/route

๐Ÿงพ Form State (useDynamicForm)

Holds the data, errors, touched and submission state for a set of fields. React and Vue export useDynamicForm; Svelte exports createDynamicForm, built on runes; Angular exports createDynamicFormStore, built on signals. All of them expose the same surface.

// React
import { useDynamicForm, MultiFieldInput } from '@dynamic-field-kit/react';

const form = useDynamicForm({
  fields,
  initialValues: { country: 'VN' },
  validateOnBlur: true, // default
  validateOnChange: false, // default
});

<form onSubmit={form.handleSubmit((data) => save(data))}>
  {/* `form` wires properties, onChange, onBlurField and touched at once */}
  <MultiFieldInput fieldDescriptions={fields} form={form} />
  <button disabled={form.isSubmitting}>
    {form.isSubmitting ? 'Savingโ€ฆ' : 'Save'}
  </button>
</form>;

Passing touched (which form does for you; Angular binds [touched]="store.touched()") hands the form store ownership of it, the same way properties/onChange already own the data. That is what makes an invalid submit visible: handleSubmit calls touchAll() before validating, so a renderer that gates its error on touched shows it even for fields the user never focused, and reset() clears it again. Omit it and MultiFieldInput keeps its own blur-only tracker, which only a ref (resetTouched()) can clear.

The map is keyed by full path, so it reaches inside repeatable groups: touchAll() produces contacts[0].email, not contacts, and a group item reports its blur under the same key. Fields that are hidden by appearCondition or disabled are left out - validation skips them too, so they can never carry an error to reveal.

// Vue โ€” same names, refs instead of plain values
const form = useDynamicForm({ fields });
form.data.value;
form.isSubmitting.value;

// Angular โ€” same names, signals
const store = createDynamicFormStore({ fields });
store.data();
store.isSubmitting();
Member Description
data Current form data, with computeValue fields applied
errors Record<string, string[]>, keyed like validateFields
isValid / isDirty No errors recorded / any value has changed
isSubmitting / isSubmitted In-flight submit / at least one submit attempted
touched Fields that have been blurred
handleChange(data) Replace the whole form data โ€” pass to MultiFieldInput's onChange
setFieldValue(name, value) Change one field
handleBlur(name) Mark touched, and validate when validateOnBlur
setFieldTouched(name, value?) Set touched explicitly
touchAll() Mark every field touched โ€” handleSubmit already calls it
resetTouched() Clear touched only, leaving data/errors/dirty alone
validate() Validate now, returns a boolean
reset(values?) Back to initialValues (or the values given), clearing errors/touched/submission
handleSubmit(onValid, onInvalid?) Returns a submit handler; calls preventDefault, validates, then dispatches

handleSubmit returns a handler in every framework, so Angular binds it the same way: onSubmit = this.store.handleSubmit((data) => โ€ฆ) then (ngSubmit)="onSubmit($event)".

Errors from the server

A form's own rules cannot know that an email is already registered. When the backend refuses a submit, hand its answer to the form:

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

const onSubmit = form.handleSubmit(async (data) => {
  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(data),
  });
  if (response.status === 400 || response.status === 422) {
    form.setServerErrors(
      serverErrorsFrom(await response.json(), {
        paths: collectFieldPaths(fields, data),
      }),
    );
  }
});

setServerErrors takes a message or a list of messages by field path ({ email: 'Already registered', 'contacts[1].phone': ['Not a phone number'] }). The errors appear in errors, after the field's own, and their fields are marked touched so they are displayed. An error stays until the value it was about changes; clearServerErrors(name?), reset() and the next submit remove them too. serverErrors holds them on their own. isValid is not affected: it speaks for the form's own rules, so a submit button bound to it stays usable for another try.

serverErrorsFrom reads what backends commonly send: a map of field to messages, that map under errors (ASP.NET, Laravel) or fieldErrors (a flattened Zod error), and a list under errors or issues whose items name their field (field, path, name, property, param, pointer, source.pointer) and carry a message, detail, msg or constraints. JSON Pointers and dotted paths become the kit's contacts[1].phone. With paths, a key that differs from a form field only by case (Email) lands on that field. What names no field is kept under FORM_ERROR_KEY, for a message above the form.

The same three members exist on useDynamicForm (React, Vue), createDynamicForm (Svelte) and createDynamicFormStore (Angular, where serverErrors is a signal).

๐Ÿงญ Multi-Step Wizard

A framework-agnostic state machine over grouped FieldDescriptions. State is immutable โ€” every navigation returns a new state object.

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

const steps = [
  { id: 'account', title: 'Account', fields: accountFields },
  { id: 'profile', title: 'Profile', fields: profileFields },
];

let wizard = createWizardState(steps);

function next(data) {
  // goNext does not validate โ€” decide for yourself whether the step may be left
  const { valid, errors } = validateStep(wizard.currentStep, data);
  if (!valid) return errors;
  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; defaults to the current one
isStepCompleted(state, index) For rendering a step indicator

WizardState carries currentStep, currentStepIndex, totalSteps, isFirstStep, isLastStep, steps and completedSteps.

๐Ÿ› ๏ธ DevTools

A floating overlay showing live form data, errors, metadata and field descriptions. Render it next to your form during development.

import { DynamicFormDevTools } from '@dynamic-field-kit/react'; // or /vue

<DynamicFormDevTools
  data={form.data}
  errors={form.errors}
  touched={form.touched}
  isDirty={form.isDirty}
  fields={fields}
  position="bottom-right" // or "bottom-left"
/>;
<!-- Angular: DynamicFormDevToolsComponent -->
<dfk-dev-tools [data]="store.data()" [errors]="store.errors()"></dfk-dev-tools>

The collapsed button carries a red badge with the number of fields in error.

๐Ÿงฎ Group Array Helpers

MultiFieldInput renders add/remove controls for repeatable groups on its own. These helpers are for driving a group's array yourself โ€” a drag handle, a "duplicate row" button, a custom group renderer:

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

const reordered = moveGroupItem(items, 3, 0); // returns the SAME array 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);

All of them are pure โ€” they return a new array and never mutate the input.


โ–ถ๏ธ Runnable Examples

example/ holds a working app per framework. They consume the packages through file: paths, so build the packages first:

npm run build                     # from the repo root
cd example/react-app && npm install && npm run dev
Page Shows
/ (react, vue, angular, svelte) Registering renderers, MultiFieldInput, layouts, conditions, repeatable groups
/new-features (react) useDynamicForm, the extended HTML5 renderers, blur wiring via onBlurField, DynamicFormDevTools
/wizard (react) The wizard engine end to end: step indicator from completedSteps, per-step validateStep, goNext / goPrev
/schema-form (react) A form built by fieldsFromJsonSchema, kept across reloads by createFormDraft, with undo and redo
"JSON Schema + Undo" tab (vue, angular, svelte) The same three helpers through the Vue composable, the Angular signal store and the Svelte form

CI builds all four example apps on every PR, so the code above is guaranteed to compile against the current packages.


๐Ÿ“– Framework-Specific Usage

For detailed setup and component API:


โž• Adding a New Field Type

You do not need to modify the library. Just extend FieldTypeMap in your application:

declare module '@dynamic-field-kit/core' {
  interface FieldTypeMap {
    date: Date;
    myCustom: any;
  }
}

Then register renderers using the framework-specific adapter:

  • React: import { fieldRegistry } from "@dynamic-field-kit/react"
  • Angular: import { fieldRegistry } from "@dynamic-field-kit/angular"
  • Vue: import { fieldRegistry } from "@dynamic-field-kit/vue"

Now your custom types are fully type-safe throughout the codebase.


๐Ÿง  Domain Typing (Optional)

The library intentionally avoids enforcing domain types. If you want strict typing, cast inside your app:

interface UserForm {
  age: number;
}

const fields: FieldDescription[] = [
  {
    name: 'age',
    type: 'number',
    appearCondition: (data) => (data as UserForm).age > 18,
  },
];

This keeps the library generic while allowing strict typing in the app.


๐Ÿ— Architecture

The monorepo contains framework-agnostic core and framework-specific adapters:

dynamic-field-kit (monorepo)
โ”œโ”€ packages/
โ”‚  โ”œโ”€ core        # Framework-agnostic types and registry
โ”‚  โ”œโ”€ react       # React components & DynamicInput
โ”‚  โ”œโ”€ angular     # Angular components & DynamicFieldKitModule
โ”‚  โ”œโ”€ vue         # Vue 3 components
โ”‚  โ””โ”€ svelte      # Svelte 5 components
โ”œโ”€ example/       # Demo apps and integration guides
โ””โ”€ .github/       # Copilot AI agent instructions

All packages share the same fieldRegistry instance, so registrations are visible across frameworks (in the same process).


๐Ÿšซ Non-Goals

Form state, validation and a set of HTML renderers used to be out of scope; they are all in the kit now (see above). What it still leaves to you:

  • A design system. The built-in React and Vue renderers are plain HTML inputs that take a className and nothing more, and Angular ships none. Styling, and wiring in a component library, happen in your renderers.
  • A form builder UI. The kit renders a form from a field list; it has no editor for writing one. It is meant to sit under a builder, not be one.
  • Sending the data. handleSubmit hands you the values; there is no HTTP client, retry or submission transport.
  • A full schema language. The built-in validators cover the common rules. For anything richer, write the schema in Zod, Yup, Valibot or any Standard Schema library and plug it in through the adapters.

It is a form engine, not a UI kit.

๐Ÿš€ Releasing

Versions are never edited by hand. Go to Actions โ†’ Release โ†’ Run workflow, run it on develop, and fill in:

Input Meaning
bump patch, minor or major
packages core,react,vue,angular,svelte โ€” leave empty to bump all of them
message The CHANGELOG entry for this release
dry_run Version and print the result without committing, tagging or publishing

The workflow runs the full quality gates first, then applies the bump, writes the CHANGELOGs, commits chore(release): version packages, and publishes to npm.

Packages are versioned independently, so bumping only what changed is fine. Any changesets already committed (npx changeset, or npm run changeset:auto -- --bump minor --packages core --message "...") are consumed in the same run, and the largest bump per package wins.

Run it on develop, not master: master requires status checks, and those apply to direct pushes too, so the Actions bot cannot push the release commit there. The new versions reach master through the usual develop โ†’ master PR.

๐Ÿ“„ License

MIT ยฉ vannt-dev

๐Ÿค Contributing

Contributions welcome! Please see individual package READMEs for setup and development instructions.

Validation messages

Set the built-in validators' messages once for a whole form instead of passing a string to every validator on every field:

const form = useDynamicForm({
  fields,
  messages: {
    required: 'Bแบฏt buแป™c',
    minLength: 'Tแป‘i thiแปƒu {min} kรฝ tแปฑ',
    matches: 'Phแบฃi khแป›p {other}',
  },
});

A message passed directly to a validator still wins, and any key you omit falls back to the English default. For code that calls validateFields directly and has no form to hang a catalog on, setDefaultMessages(catalog) sets a process-wide one; a per-form catalog takes precedence over it.

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 library. Supply your own catalog โ€” the mechanism is here, the translations are yours.

A placeholder with no matching param is left in the string verbatim rather than replaced with undefined, so a typo shows up as a visible {unit} instead of a mystery.

Async options

options can return a promise. Two shapes are covered, and the difference is what triggers a reload.

Dependent options โ€” the reload is driven by form data:

{
  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,
}

Search-remote โ€” the reload is driven by the renderer's own search box, which the form data never sees. The renderer calls onOptionsQuery:

{
  name: 'assignee',
  type: 'userPicker',
  options: async (_data, _rootData, ctx) =>
    fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
      .then((r) => r.json()),
  debounceMs: 300,
}

The renderer receives optionsStatus ('idle' | 'loading' | 'ready' | 'error'), optionsError, and onOptionsQuery.

Field property Effect
optionsDeps Values a reload depends on, compared shallowly. Defaults to [] โ€” fetch once.
optionsMode 'async' for a loader that returns a promise without the async keyword.
debounceMs Collapses rapid reloads into one fetch. Applies to async options only.

Superseded requests are aborted through ctx.signal, and a slow response that lands after a newer one is discarded, so the option list always reflects the most recent request rather than the last one to arrive.

Native async functions are detected automatically. A loader wrapped in a memoiser, a spy or a transpiler helper is not โ€” constructor.name is no longer 'AsyncFunction'. Declare optionsMode: 'async' for those; without it the promise is dropped and a development warning says so.

Note that a form whose properties arrive after mount sees its data change twice (empty, then loaded), which is two loads without a debounceMs. Setting one collapses them.

The loader is bound to the field description it first saw. Changing a field's name swaps it โ€” every adapter keys each field by name, so that remounts โ€” but changing only the options closure on a same-named field does not. That is deliberate: a fields array built inline in a component body gets a fresh closure on every render, and rebuilding on closure identity would refetch in a loop.

Releases

Packages

Contributors

Languages