Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f9faf9e
chore(ci): carry a release on one tag instead of four
vannt-dev Aug 30, 2026
0d1b997
fix(ci): stop demanding a changeset on release syncs into master
vannt-dev Aug 30, 2026
622d811
chore(npm): make the packages findable, and land visitors on the demo
vannt-dev Aug 30, 2026
efed6e5
chore(release): version packages
github-actions[bot] Aug 30, 2026
2b0c228
feat(core): own the renderer prop contract instead of merely declarin…
vannt-dev Sep 3, 2026
96337ed
fix(react): give touched one owner and every field a unique id
vannt-dev Sep 3, 2026
405a3dd
fix(vue): give touched one owner, unique ids, and the full prop set
vannt-dev Sep 3, 2026
891f7d4
fix(angular): let renderers see touched, dirty and id at all
vannt-dev Sep 3, 2026
8adeecb
test(ci): fail the build when an adapter stops forwarding a contract …
vannt-dev Sep 3, 2026
ceacf30
docs: document the enforced prop contract, id namespacing and touched…
vannt-dev Sep 3, 2026
d2e6df6
feat: complete live and async form validation (#54)
vannt-dev Sep 3, 2026
a0374dd
chore(deps): upgrade to Angular 21 and TypeScript 5.9
vannt-dev Sep 3, 2026
29e6b09
feat(validation): make async validation cancellable and status-aware
vannt-dev Sep 3, 2026
281cc64
docs: bring the READMEs in line with the validation API
vannt-dev Sep 3, 2026
79ef6d6
fix: declare the peer ranges the packages can actually satisfy
vannt-dev Sep 3, 2026
98f9708
feat: re-export the new core helpers from every adapter
vannt-dev Sep 3, 2026
ad7acd3
test(ci): link the published bundle with the floor Angular's own linker
vannt-dev Sep 3, 2026
aa054f4
test(ci): fail the build when the docs import something that does not…
vannt-dev Sep 3, 2026
9d80530
chore(release): version packages
github-actions[bot] Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ jobs:
changeset:
name: Changeset present
runs-on: ubuntu-latest
# Skipped on the release PR changesets itself opens: that PR consumes the
# changesets and bumps versions, so it changes packages while legitimately
# having none left, and would always fail this check.
# Only PRs into develop can be missing a changeset, because develop is where
# unreleased work accumulates. Skipped elsewhere:
#
# - the release PR changesets itself opens, which consumes the changesets
# and bumps versions, so it changes packages while legitimately having
# none left;
# - any PR into master, which carries changes that were already released
# from develop - their changesets are gone by definition, and demanding a
# new one would mean releasing the same work twice.
if: >-
github.event_name == 'pull_request' &&
github.base_ref == 'develop' &&
!startsWith(github.head_ref, 'changeset-release/')
steps:
- uses: actions/checkout@v7
Expand Down
24 changes: 19 additions & 5 deletions .github/workflows/quality-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,26 @@ jobs:
run: |
node scripts/verify-framework-deps.js
node scripts/check-cross-framework-imports.js
node scripts/check-renderer-prop-parity.js
node scripts/verify-package-entrypoints.js
node scripts/check-docs-api-references.js
node scripts/integration-cross-registry.js

# Packs the built packages and renders them under every React major the
# peerDependencies range claims, in a throwaway project outside the
# workspace. Separate from the step above because it installs from the
# network, so it is the one verification step that can fail for reasons
# unrelated to the code.
# Packs the built packages and exercises them under both ends of every
# framework range the peerDependencies claim, in throwaway projects
# outside the workspace. Separate from the step above because these
# install from the network, so they are the verification steps that can
# fail for reasons unrelated to the code.
#
# React and Vue render; Angular only imports and checks the registry -
# its published fesm2022 needs the CLI's linker to instantiate a
# component. That is still the level that breaks across majors, which is
# how a declared floor of 14 survived years of using signal() (16+).
- name: Verify the declared React peer range
run: node scripts/verify-react-peer-range.js

- name: Verify the declared Vue peer range
run: node scripts/verify-vue-peer-range.js

- name: Verify the declared Angular peer range
run: node scripts/verify-angular-peer-range.js
18 changes: 15 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,24 @@ jobs:
git push origin HEAD:${{ github.ref_name }}

# changeset publish pushes each package that is not already on npm at its
# current version, and tags the commit per package.
# current version. Its own tagging is off: it writes one tag per package,
# and with all four on a single version line that is the same release
# stated four times. One annotated tag carries it instead, named after
# core - the anchor of the version line - with the exact version of each
# package in its body, which is what disambiguates the case where the
# `linked` group lets one package move on its own.
- name: Publish to npm
if: ${{ !inputs.dry_run }}
run: |
npx changeset publish
git push origin --follow-tags
npx changeset publish --no-git-tag

version=$(node -p "require('./packages/core/package.json').version")
published=$(for pkg in core react vue angular; do
echo "@dynamic-field-kit/$pkg@$(node -p "require('./packages/$pkg/package.json').version")"
done)

git tag -a "v$version" -m "Release v$version" -m "$published"
git push origin "v$version"
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Expand Down
71 changes: 58 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, b
- **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`.
- **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.
- **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
Expand Down Expand Up @@ -66,7 +67,9 @@ 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.
validate through `validateFieldsAsync` instead, and mark the field
`validationMode: 'async'` so the live pass skips it rather than calling it on
every keystroke.

---

Expand Down Expand Up @@ -154,17 +157,36 @@ declare module '@dynamic-field-kit/core' {
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
👉 A common contract for all field renderers — and an enforced one. Core builds
the bag in `buildFieldRendererProps`, all three adapters call 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)**

Expand Down Expand Up @@ -288,7 +310,8 @@ const fields: FieldDescription[] = [

| Property | Description |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| validate | `(value, data, rootData?) => string \| string[] \| undefined \| Promise<...>`. Falsy means valid. |
| validate | `(value, data, rootData?, context?) => string | string[] | undefined | Promise<...>`. Falsy means valid. `context.signal` aborts when a newer run supersedes this one. |
| validationMode | `'sync' | 'async'`. Declares a validator that returns a Promise without the `async` keyword, so the live pass skips it instead of calling it. |
| validators | Built-in helpers: `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `compose` |
| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` |
| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. |
Expand All @@ -304,13 +327,23 @@ group items) call `validateFields` or `validateFieldsAsync`:
```ts
import { validateFields, validateFieldsAsync } from '@dynamic-field-kit/core';

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

// Or for async validation:
const asyncResult = await validateFieldsAsync(fields, data);
// 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`) ship with **built-in HTML5 fallback renderers**:
Expand Down Expand Up @@ -395,18 +428,28 @@ const form = useDynamicForm({
});

<form onSubmit={form.handleSubmit((data) => save(data))}>
<MultiFieldInput
fieldDescriptions={fields}
properties={form.data}
onChange={form.handleChange}
onBlurField={form.handleBlur} // wires touched + validateOnBlur
/>
{/* `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.

```ts
// Vue — same names, refs instead of plain values
const form = useDynamicForm({ fields });
Expand All @@ -430,6 +473,8 @@ store.isSubmitting();
| `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 |
Expand Down
Loading