Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3dad096
feat(react): expose baselineValues and getDirtyValues from useDynamic…
vannt-dev Sep 4, 2026
94e5d9b
fix(react): re-base per-field dirty on reset and late-arriving values
vannt-dev Sep 4, 2026
99e9ef2
perf(react): stop validating the same data twice per change
vannt-dev Sep 4, 2026
65e0a56
feat(vue): expose baselineValues and getDirtyValues from useDynamicForm
vannt-dev Sep 4, 2026
422197b
fix(vue): re-base per-field dirty on reset and late-arriving values
vannt-dev Sep 4, 2026
72e9c5e
feat(angular): expose baselineValues and getDirtyValues from the form…
vannt-dev Sep 4, 2026
7732a07
fix(angular): let the dirty baseline be re-based after a reset
vannt-dev Sep 4, 2026
76b4a16
docs: add migration guide and changeset for the 1.7.0 form-state fixes
vannt-dev Sep 4, 2026
a4b18ed
fix(core): make ariaDescribedBy point at a real error node id
vannt-dev Sep 4, 2026
d54837a
feat(core): warn in dev when props shadows a renderer contract key
vannt-dev Sep 4, 2026
e433212
feat(react): render a validation message for default renderers
vannt-dev Sep 4, 2026
6426ced
feat(vue): render a validation message for default renderers
vannt-dev Sep 4, 2026
0572805
feat(angular): render a validation message for default renderers
vannt-dev Sep 4, 2026
fb7afed
docs: aria wiring recipe and 1.7.0 renderer-contract migration notes
vannt-dev Sep 4, 2026
e7a44a1
feat(core): add a message catalog and resolver
vannt-dev Sep 4, 2026
a194272
feat(core): resolve validator messages at validate time, add matches
vannt-dev Sep 4, 2026
611527c
feat(core): thread the message context through validation
vannt-dev Sep 4, 2026
2103537
feat(react): accept a messages catalog in useDynamicForm
vannt-dev Sep 4, 2026
a93c493
feat(vue): accept a messages catalog in useDynamicForm
vannt-dev Sep 4, 2026
11a189d
feat(angular): accept a messages catalog in the form store
vannt-dev Sep 4, 2026
7ca2354
docs: message catalog reference and 1.7.0 i18n migration notes
vannt-dev Sep 4, 2026
f5c1421
feat(core): add createOptionsLoader for async, debounced field options
vannt-dev Sep 4, 2026
54619e7
feat(core): carry options loading state in the renderer prop bag
vannt-dev Sep 4, 2026
ca09e84
feat(react): load async field options with debounce and abort
vannt-dev Sep 4, 2026
5a461ce
feat(vue): load async field options with debounce and abort
vannt-dev Sep 4, 2026
f74a88d
feat(angular): load async field options with debounce and abort
vannt-dev Sep 4, 2026
1bbdfe7
docs: async options reference and 1.7.0 migration notes
vannt-dev Sep 4, 2026
930ef64
style: format the angular store spec
vannt-dev Sep 4, 2026
2cc098f
docs: bring the four package READMEs up to date with 1.7.0
vannt-dev Sep 4, 2026
720f964
fix: address the code review findings on the 1.7.0 work
vannt-dev Sep 4, 2026
2d3042d
test: characterise the options loader's binding to its field description
vannt-dev Sep 4, 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
21 changes: 21 additions & 0 deletions .changeset/brave-melons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@dynamic-field-kit/core': minor
'@dynamic-field-kit/react': minor
'@dynamic-field-kit/vue': minor
'@dynamic-field-kit/angular': minor
---

Validation messages can be set once per form via `useDynamicForm({ messages })`,
or process-wide via `setDefaultMessages`, instead of passing a string to every
validator on every field. Built-in validators now resolve their message when
they run rather than when the field description is built, which is what made a
catalog impossible before. A message passed directly to a validator still wins,
and the English defaults are unchanged when no catalog is supplied.

`ValidationContext` - already `validate`'s fourth argument - gains an optional
`t` resolver, so a hand-written validator can translate its own messages too.

Adds `validators.matches(otherFieldName)` for confirm-password and
confirm-email fields, which every consumer was hand-writing.

No locale bundles ship: the mechanism is here, the translations are yours.
18 changes: 18 additions & 0 deletions .changeset/great-pugs-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@dynamic-field-kit/core': minor
'@dynamic-field-kit/react': minor
'@dynamic-field-kit/vue': minor
'@dynamic-field-kit/angular': minor
---

Fix per-field `dirty`, which was measured against a baseline captured at mount
and never re-based - wrong after `reset(newValues)` on all three adapters, and
wrong on React and Vue for values that arrive after mount, where every field
reported dirty forever.

Adds `baselineValues` and `getDirtyValues()` to the form store on all three
adapters, and an `initialProperties` prop to `MultiFieldInput` for re-basing
without a store. Comparison moves from `!==` to `Object.is`, so a `NaN` numeric
field no longer reads as permanently dirty.

React's `useDynamicForm` no longer validates the same data twice per change.
19 changes: 19 additions & 0 deletions .changeset/olive-hounds-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@dynamic-field-kit/core': minor
'@dynamic-field-kit/react': minor
'@dynamic-field-kit/vue': minor
'@dynamic-field-kit/angular': minor
---

`options` can now return a promise, covering both dependent selects
(`optionsDeps`) and search-remote pickers (`onOptionsQuery`). Renderers receive
`optionsStatus` and `optionsError` alongside `options`.

`debounceMs` was declared on `FieldDescription`, published in the `.d.ts` and
read by no implementation anywhere - setting it did nothing. It now debounces
these loads.

Debounce, abort of a superseded request, and discarding a response that lands
out of order all live in core's `createOptionsLoader`, so the three adapters
share one implementation. Synchronous and static options are untouched and never
enter a loading state.
19 changes: 19 additions & 0 deletions .changeset/olive-pumas-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@dynamic-field-kit/core': minor
'@dynamic-field-kit/react': minor
'@dynamic-field-kit/vue': minor
'@dynamic-field-kit/angular': minor
---

`ariaDescribedBy` is now `${id}-error` when a field has an error instead of
being hard-coded `undefined`, and `makeErrorId` is exported so a custom renderer
can put the matching id on its message element. Without this,
`focusFirstInvalidField` had nothing to find for anyone following the official
renderer recipe.

Default renderers now render the validation message they were already being
handed - the one visible change in this release. Custom renderers are untouched,
so nobody gets two copies of their own message.

Development builds now warn when `FieldDescription.props` carries a key the
renderer prop contract owns, which 1.6.0 made possible to lose silently.
117 changes: 109 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,14 @@ const fields: FieldDescription[] = [
];
```

| Property | Description |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| 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. |
| readOnlyCondition | `(data, rootData?) => boolean`. |
| Property | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
| 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`, `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
Expand Down Expand Up @@ -722,3 +722,104 @@ MIT © [vannt-dev](https://github.com/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:

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

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

```ts
{
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.
Loading