@dynamic-field-kit/react 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,229 @@
1
1
  # @dynamic-field-kit/react
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Dirty-baseline rebasing, accessible validation errors, form-level message catalogs, and async field options across React, Vue, and Angular.
8
+ - 9b06e3f: Validation messages can be set once per form via `useDynamicForm({ messages })`,
9
+ or process-wide via `setDefaultMessages`, instead of passing a string to every
10
+ validator on every field. Built-in validators now resolve their message when
11
+ they run rather than when the field description is built, which is what made a
12
+ catalog impossible before. A message passed directly to a validator still wins,
13
+ and the English defaults are unchanged when no catalog is supplied.
14
+
15
+ `ValidationContext` - already `validate`'s fourth argument - gains an optional
16
+ `t` resolver, so a hand-written validator can translate its own messages too.
17
+
18
+ Adds `validators.matches(otherFieldName)` for confirm-password and
19
+ confirm-email fields, which every consumer was hand-writing.
20
+
21
+ No locale bundles ship: the mechanism is here, the translations are yours.
22
+
23
+ - a7358f9: Fix per-field `dirty`, which was measured against a baseline captured at mount
24
+ and never re-based - wrong after `reset(newValues)` on all three adapters, and
25
+ wrong on React and Vue for values that arrive after mount, where every field
26
+ reported dirty forever.
27
+
28
+ Adds `baselineValues` and `getDirtyValues()` to the form store on all three
29
+ adapters, and an `initialProperties` prop to `MultiFieldInput` for re-basing
30
+ without a store. Comparison moves from `!==` to `Object.is`, so a `NaN` numeric
31
+ field no longer reads as permanently dirty.
32
+
33
+ React's `useDynamicForm` no longer validates the same data twice per change.
34
+
35
+ - 53ed45a: `options` can now return a promise, covering both dependent selects
36
+ (`optionsDeps`) and search-remote pickers (`onOptionsQuery`). Renderers receive
37
+ `optionsStatus` and `optionsError` alongside `options`.
38
+
39
+ `debounceMs` was declared on `FieldDescription`, published in the `.d.ts` and
40
+ read by no implementation anywhere - setting it did nothing. It now debounces
41
+ these loads.
42
+
43
+ Debounce, abort of a superseded request, and discarding a response that lands
44
+ out of order all live in core's `createOptionsLoader`, so the three adapters
45
+ share one implementation. Synchronous and static options are untouched and never
46
+ enter a loading state.
47
+
48
+ - e35e876: `ariaDescribedBy` is now `${id}-error` when a field has an error instead of
49
+ being hard-coded `undefined`, and `makeErrorId` is exported so a custom renderer
50
+ can put the matching id on its message element. Without this,
51
+ `focusFirstInvalidField` had nothing to find for anyone following the official
52
+ renderer recipe.
53
+
54
+ Default renderers now render the validation message they were already being
55
+ handed - the one visible change in this release. Custom renderers are untouched,
56
+ so nobody gets two copies of their own message.
57
+
58
+ Development builds now warn when `FieldDescription.props` carries a key the
59
+ renderer prop contract owns, which 1.6.0 made possible to lose silently.
60
+
61
+ ## 1.6.0
62
+
63
+ ### Minor Changes
64
+
65
+ - Cancellable, status-aware async validation; touched state that reaches inside repeatable groups; Angular 21 and TypeScript 5.9 support; and peer ranges corrected to the versions that actually work, proven at both ends in CI.
66
+ - 67e4eec: Give every adapter one renderer-prop contract, unique field ids, and a touched
67
+ state the form store can actually drive.
68
+
69
+ Five things went wrong at once for anyone building a real form on 1.5.1, and
70
+ four of them share a cause: `FieldRendererProps` was a type nobody enforced.
71
+ Each adapter hand-wrote the object it handed the registered renderer, and the
72
+ three lists drifted. React dropped `placeholder`, `min`, `max`, `step`,
73
+ `accept` and `multiple`. Vue dropped `required`, `id`, `dirty` and the aria
74
+ flags. Angular dropped `touched`, `dirty` and `id` — so an Angular renderer had
75
+ no way to know whether a field had been touched, and "only show the error once
76
+ the user leaves the field" had to be rebuilt by hand. Setting
77
+ `placeholder` on a `FieldDescription` therefore did nothing at all on React and
78
+ Vue: no error, no warning, the value simply vanished. Core now owns the list as
79
+ `FIELD_RENDERER_PROP_KEYS` and builds the bag once in
80
+ `buildFieldRendererProps`, which all three adapters call, and
81
+ `scripts/check-renderer-prop-parity.js` fails the build if an adapter stops
82
+ forwarding one. The single deliberate deviation is Vue's `class` in place of
83
+ `className`: forwarding `className` lets it fall through to a renderer's root
84
+ element, where Vue assigns `el.className` and wipes the class the renderer set
85
+ on itself.
86
+
87
+ Field ids were `dfk-field-${name}`, derived from the field name alone. Two
88
+ forms holding a field of the same name — a create form beside an edit form, the
89
+ most ordinary layout there is — emitted the same DOM id twice, which is invalid
90
+ HTML and leaves every `label[for]` pointing at two inputs. Ids are now
91
+ namespaced per `MultiFieldInput` instance (React `useId`, so it is SSR-safe;
92
+ Vue's instance uid; a counter on Angular). Set `idPrefix` to pin them —
93
+ `idPrefix="dfk-field"` reproduces the old ids exactly — or give a single field
94
+ its own id with the new `FieldDescription.id`.
95
+
96
+ Touched state had two independent trackers that never met: the one in
97
+ `useDynamicForm`, and a private one inside `MultiFieldInput` that only blur
98
+ could set and that was the one renderers actually saw. So
99
+ `setFieldTouched` in an `onInvalid` handler changed nothing visible, submitting
100
+ a form nobody had focused showed no errors at all (the button looked broken),
101
+ and `reset()` could not clear the touched state a previous submit had left
102
+ behind. `MultiFieldInput` now accepts `touched` as a controlled prop —
103
+ `useDynamicForm` becomes the single source of truth for it, exactly as
104
+ `properties`/`onChange` already were for data — plus `onTouchedChange`, and a
105
+ `form` shorthand (React and Vue) that wires data, change, blur and touched in
106
+ one prop. `handleSubmit` marks every field touched before validating, and the
107
+ new `touchAll()`/`resetTouched()` sit alongside it. Omit `touched` and the old
108
+ internal tracker still runs, so nothing breaks; for that mode a ref
109
+ (`resetTouched()` on React and Vue, a public method on Angular) can clear it
110
+ without remounting the component.
111
+
112
+ The only behaviour change to watch for is the generated ids. Anything pinned to
113
+ a literal `dfk-field-*` id in CSS or a test needs either `idPrefix="dfk-field"`
114
+ or a per-field `id`. Angular's `MultiFieldInput` also loses four undocumented
115
+ template helpers — `getResolvedOptions`, `getDisabled`, `getReadOnly` and
116
+ `getError` — which its own template no longer calls now that `FieldInput`
117
+ resolves all of it through core. Keeping a second copy of that logic beside the
118
+ shared one is how the adapters drifted apart to begin with; the equivalents are
119
+ `resolveOptions`, `resolveDisabled`, `resolveReadOnly` and `validateField`,
120
+ already re-exported from this package.
121
+
122
+ Form validity now reflects current data immediately instead of merely checking
123
+ the lazily populated `errors` map. The error map remains lazy for display, and
124
+ passing a form binding (or the new controlled `errors` input) makes that same
125
+ map the renderer's source of truth, removing the previous timing mismatch.
126
+
127
+ Promise-based validators are no longer silently accepted on submit.
128
+ `validateFields` reports unresolved field names in `pending`; every framework
129
+ form helper uses one async-capable validation pass before dispatching submit
130
+ callbacks. React, Vue and Angular also expose
131
+ `validateAsync()` for explicit pre-submit checks. Live `isValid` remains a
132
+ synchronous answer because a property/computed/signal cannot await.
133
+
134
+ The new UI-kit recipes show complete touched/error wiring for Ant Design,
135
+ Vuetify and Angular Material.
136
+
137
+ - 5e0b08f: Make async validation answerable: a status you can act on, runs that cancel
138
+ cleanly, and touched state that reaches inside repeatable groups.
139
+
140
+ `ValidationResult` gains `complete` and `status` (`'valid' | 'invalid' |
141
+ 'pending'`). Combining `valid` with `pending` was the only way to tell "nothing
142
+ is wrong" from "nothing is wrong _yet_", and everyone got it wrong the same
143
+ way — a `valid: true` with async rules still in flight reads as a green light.
144
+ `status` is the single answer; `complete` says whether every applicable
145
+ validator finished. Both are always present on a result the library returns, so
146
+ reading them needs no fallback; code that constructs a `ValidationResult` by
147
+ hand (a mock, a wrapper typed to return one) has to supply them.
148
+
149
+ `FieldDescription.validationMode: 'async'` declares a validator that returns a
150
+ Promise without the `async` keyword, which detection cannot see. Declaring it
151
+ keeps the synchronous pass from invoking the validator at all — and, unlike
152
+ detection, it is an explicit opt-in, so the dev warning about a field the live
153
+ pass cannot check stays quiet for it.
154
+
155
+ `validateFieldsAsync` now takes a `ValidationContext` and forwards its
156
+ `AbortSignal` to every validator, runs independent validators in parallel
157
+ instead of awaiting them one after another, skips validators once the signal is
158
+ aborted, and reports an aborted run as `complete: false` / `status: 'pending'`.
159
+ A validator that honours the signal the conventional way — rejecting with an
160
+ `AbortError` — no longer rejects the caller's `handleSubmit`; an error that is
161
+ not an abort still propagates.
162
+
163
+ Each adapter's form helper exposes `isValidating`, `isValidationComplete` and
164
+ `validationStatus`, and applies latest-run-wins: typing cancels an in-flight
165
+ live validation so a stale result cannot overwrite a newer one. A submit is not
166
+ collateral damage of that — it validates the snapshot the user submitted under
167
+ a controller of its own, so editing a field mid-flight no longer leaves the
168
+ form with the submit silently dropped, no `onValid`/`onInvalid`, and a button
169
+ that just re-enables.
170
+
171
+ `touchAll()` now expands to the concrete leaf paths that exist in the data
172
+ (`contacts[0].email`, not `contacts`) via the new `collectFieldPaths`, skipping
173
+ fields validation itself skips — hidden by `appearCondition`, or disabled.
174
+ Repeatable group items receive `touched` and report blur with their full path,
175
+ so a UI kit that only shows an error once a field is touched now works inside a
176
+ group. An item with no touched keys still receives a map rather than
177
+ `undefined`, which previously flipped the nested input into tracking touched by
178
+ itself and left it stale after the owner cleared the map. The new
179
+ `indexGroupPathMap` is what indexes those maps by item, exported so a custom
180
+ renderer can do the same without filtering the whole map per item.
181
+
182
+ React's `isValid` is now seeded from the initial data instead of from an
183
+ effect. An effect never runs on the server, so a server-rendered form shipped
184
+ `isValid: true` for an empty required field and never corrected it — a submit
185
+ button rendered enabled and stayed that way.
186
+
187
+ `@dynamic-field-kit/angular`'s `types` entry pointed at `dist/index.d.ts`,
188
+ which is not where its type declarations are emitted any more; it and the
189
+ `exports` block now point at the file that actually ships, so TypeScript
190
+ consumers resolve the package's types again.
191
+
192
+ - eec9386: Correct the peer ranges to the ones that actually work, and prove both ends of
193
+ each in CI.
194
+
195
+ `@dynamic-field-kit/angular` declared `@angular/core` and `@angular/common` as
196
+ `>=14 <22`, but the form store is built on `signal` and `computed`, which
197
+ Angular introduced in **16**. On 14 or 15 npm accepted the install and the
198
+ package then failed on import - the manifest promised something it could not
199
+ do. The range is now `>=16 <22`, so the same install is refused up front.
200
+
201
+ `@dynamic-field-kit/vue` moves from `vue ^3.0.0` to `^3.2.0`.
202
+ `useDynamicForm` now aborts an in-flight validation when the owning effect
203
+ scope is disposed, using `getCurrentScope` / `onScopeDispose` - both Vue 3.2.
204
+ Without this an unmounted form held its request open until the response came
205
+ back. If you are on Vue 3.0 or 3.1, stay on 1.5.x; nothing else in the package
206
+ ever required 3.2, but nothing tested below it either.
207
+
208
+ Both ranges are now verified rather than asserted:
209
+ `scripts/verify-vue-peer-range.js` server-renders the packed tarballs under Vue
210
+ 3.2 and the newest 3.x, and `scripts/verify-angular-peer-range.js` installs
211
+ them against Angular 16 and 21 and checks the package imports, its components
212
+ evaluate and it shares one registry with core. Both run in the CI verify job,
213
+ next to the React one that has existed since 1.5.0. A render is out of reach
214
+ for Angular - the published fesm2022 needs the CLI's linker to instantiate a
215
+ component - but import-and-wire is the level that breaks across majors, which
216
+ is exactly how a floor of 14 survived years of `signal()`.
217
+
218
+ The three adapters now re-export `collectFieldPaths`, `indexGroupPathMap` and
219
+ the `ValidationContext` type from core, so typing a validator's `context`
220
+ argument no longer means importing `@dynamic-field-kit/core` alongside the
221
+ adapter.
222
+
223
+ `@angular/platform-browser-dynamic`, which Angular 21 deprecates, is gone from
224
+ the package's devDependencies and from the demo app, which never used it - the
225
+ test setup now initialises through `@angular/platform-browser/testing`.
226
+
3
227
  ## 1.5.1
4
228
 
5
229
  ### Patch Changes
package/README.md CHANGED
@@ -41,16 +41,32 @@ both packages:
41
41
 
42
42
  - `validateField` / `validateFieldAsync` — one field, returns `string[]`
43
43
  - `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult`
44
+ - `collectFieldPaths` — the leaf paths a schema actually has in the data (`contacts[0].email`)
45
+ - `indexGroupPathMap` — index an error or touched map by repeatable-group item
46
+ - `makeErrorId` — the id a renderer puts on its message element so
47
+ `aria-describedby` resolves
48
+ - `createOptionsLoader` / `isAsyncOptions` — the async options engine
49
+ - `createMessageResolver` / `setDefaultMessages` / `MessageCatalog` — validation
50
+ message catalog
44
51
  - `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options
45
52
  - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …)
46
- - `ValidationResult`
47
-
48
- `useDynamicForm` validates **synchronously** via `validateFields`, including on
49
- submit. Fields whose `validate` hook returns a Promise are treated as valid on
50
- that path, so run async rules through `validateFieldsAsync` yourself. See the
53
+ - `ValidationResult` / `ValidationContext`
54
+
55
+ `useDynamicForm` keeps live validation synchronous - a validator declared or
56
+ detected as async is never invoked on that path. Its `handleSubmit` runs one
57
+ async-capable pass, and `validateAsync()` is there when you need that answer
58
+ before submit. Runs are latest-wins: typing aborts the live run in flight, so a
59
+ stale result cannot overwrite a newer one, and a submit validates the snapshot
60
+ it was given under a controller of its own, so editing mid-submit no longer
61
+ cancels it. Declare a Promise-returning validator with
62
+ `validationMode: 'async'` and read `context.signal` (the fourth argument) to
63
+ cancel the request itself. See the
51
64
  [core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation)
52
65
  for the full rules.
53
66
 
67
+ For a complete UI integration, see the
68
+ [Ant Design recipe](../../docs/ui-kit-recipes.md#react--ant-design).
69
+
54
70
  `FieldGroupInput` (repeatable field groups) is used internally by `FieldInput` and doesn't need to be imported directly - see "Repeatable field groups" below.
55
71
 
56
72
  Default layouts are registered automatically when you import the package root.
@@ -129,39 +145,112 @@ const form = useDynamicForm({
129
145
  initialValues: { country: 'VN' },
130
146
  validateOnBlur: true, // default
131
147
  validateOnChange: false, // default
148
+ messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions
132
149
  });
133
150
 
134
151
  <form onSubmit={form.handleSubmit((data) => save(data))}>
135
- <MultiFieldInput
136
- fieldDescriptions={fields}
137
- properties={form.data}
138
- onChange={form.handleChange}
139
- onBlurField={form.handleBlur} // wires touched + validateOnBlur
140
- />
152
+ <MultiFieldInput fieldDescriptions={fields} form={form} />
141
153
  <button disabled={form.isSubmitting}>
142
154
  {form.isSubmitting ? 'Saving…' : 'Save'}
143
155
  </button>
144
156
  </form>;
145
157
  ```
146
158
 
147
- | Member | Description |
148
- | ----------------------------------- | --------------------------------------------------------------------------------- |
149
- | `data` | Current form data, with `computeValue` fields applied |
150
- | `errors` | `Record<string, string[]>`, keyed like `validateFields` |
151
- | `isValid` / `isDirty` | No errors recorded / any value has changed |
152
- | `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted |
153
- | `touched` | Fields that have been blurred |
154
- | `handleChange(data)` | Replace the whole form data pass to `MultiFieldInput`'s `onChange` |
155
- | `setFieldValue(name, value)` | Change one field |
156
- | `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
157
- | `setFieldTouched(name, value?)` | Set touched explicitly |
158
- | `setData` | Raw state setter, for escape hatches |
159
- | `validate()` | Validate now, returns a boolean |
160
- | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
161
- | `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches |
162
-
163
- `MultiFieldInput` tracks touched internally regardless; `onBlurField` is the
164
- hook for driving an external store like this one.
159
+ `form` is shorthand for five state/callback props, and is the recommended wiring:
160
+
161
+ ```tsx
162
+ <MultiFieldInput
163
+ fieldDescriptions={fields}
164
+ properties={form.data}
165
+ onChange={form.handleChange}
166
+ onBlurField={form.handleBlur} // touched + validateOnBlur
167
+ touched={form.touched} // makes the hook the only source of truth
168
+ errors={form.errors} // renderer and hook read the same error map
169
+ />
170
+ ```
171
+
172
+ The `form` shorthand also carries `baselineValues`, which is what keeps
173
+ per-field `dirty` correct across a reset. Without a form store, pass the
174
+ baseline yourself:
175
+
176
+ ```tsx
177
+ <MultiFieldInput
178
+ fieldDescriptions={fields}
179
+ properties={data}
180
+ initialProperties={original} // what `dirty` compares against
181
+ />
182
+ ```
183
+
184
+ Omit it and the baseline is the first non-`undefined` `properties` the
185
+ component sees — which is what an edit form wants when its values arrive from a
186
+ fetch after mount. Note that `{}` counts as a real value: a form
187
+ that opens blank cannot be told apart from one still waiting on a fetch, so
188
+ pass `initialProperties` when `properties` starts as `{}` rather than
189
+ `undefined`.
190
+
191
+ Passing `touched` and `errors` gives the form store ownership of renderer
192
+ metadata. `touched` is what makes an invalid submit visible: `handleSubmit`
193
+ marks every field touched before validating, so a renderer that gates its error
194
+ on `touched` shows it even for fields the user never focused. `reset()` clears
195
+ touched the same way. Individually passed props win over the ones `form`
196
+ derives, so you can pass `form` and still override one wire.
197
+
198
+ | Member | Description |
199
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------- |
200
+ | `data` | Current form data, with `computeValue` fields applied |
201
+ | `errors` | `Record<string, string[]>`, keyed like `validateFields` |
202
+ | `isValid` / `isDirty` | Current synchronous validity / any value has changed |
203
+ | `baselineValues` | The values `dirty` is measured against — `initialValues` until `reset(newValues)` replaces them |
204
+ | `getDirtyValues()` | Only the entries differing from `baselineValues`, for PATCH-style submits |
205
+ | `isValidating` | An async validation pass is in flight |
206
+ | `isValidationComplete` | Every applicable validator finished and none is in flight |
207
+ | `validationStatus` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" |
208
+ | `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted |
209
+ | `touched` | Fields that have been blurred |
210
+ | `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` |
211
+ | `setFieldValue(name, value)` | Change one field |
212
+ | `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
213
+ | `setFieldTouched(name, value?)` | Set touched explicitly |
214
+ | `touchAll()` | Mark every field touched — `handleSubmit` already calls it |
215
+ | `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
216
+ | `setTouched` | Raw setter for the whole touched map |
217
+ | `setData` | Raw state setter, for escape hatches |
218
+ | `validate()` | Validate now, returns a boolean |
219
+ | `validateAsync()` | Validate now, awaiting Promise-based rules |
220
+ | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
221
+ | `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches |
222
+
223
+ Leave `touched` off and `MultiFieldInput` falls back to tracking it internally
224
+ from blur alone, as it always did. In that mode nothing outside the component
225
+ can clear it — a form that stays mounted across submits will keep showing the
226
+ errors of the previous round after `reset()` — so it exposes a ref for it:
227
+
228
+ ```tsx
229
+ const ref = useRef<MultiFieldInputHandle>(null);
230
+
231
+ <MultiFieldInput ref={ref} fieldDescriptions={fields} />;
232
+ // after a successful submit
233
+ ref.current?.resetTouched();
234
+ ```
235
+
236
+ `resetTouched()`, `setFieldTouched(name, value?)` and `getTouched()` are the
237
+ handle's members. Controlled mode needs none of them: `form.reset()` covers it.
238
+
239
+ ## Field ids
240
+
241
+ Each field renders with `id={`${idPrefix}-${name}`}`, where `idPrefix` defaults
242
+ to a value unique to the `MultiFieldInput` instance (from `useId`, so server and
243
+ client agree). Two forms containing a field of the same name therefore no longer
244
+ emit the same DOM id twice.
245
+
246
+ ```tsx
247
+ // pinned ids — reproduces the pre-1.6 `dfk-field-title`
248
+ <MultiFieldInput fieldDescriptions={fields} idPrefix="dfk-field" />
249
+ ```
250
+
251
+ For one field, set `id` on its `FieldDescription`; it wins over the prefix.
252
+ Renderers receive the resolved value as the `id` prop, so a `<label htmlFor>` in
253
+ a renderer points at exactly one input.
165
254
 
166
255
  ## Default renderers
167
256
 
@@ -183,6 +272,13 @@ const Base = getDefaultRenderer('date'); // undefined for an unknown type
183
272
  `file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number`
184
273
  emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings.
185
274
 
275
+ Since 1.7.0 a default renderer also renders its validation message, as
276
+ `<div id="{fieldId}-error" class="dfk-field-error" role="alert">`, which is what
277
+ `aria-describedby` points at. Before that they were handed `error` and dropped
278
+ it, so the form showed nothing. A registered custom renderer is unaffected — the
279
+ node is emitted only where a default was used, so you never get two copies. Hide
280
+ it with `.dfk-field-error { display: none }` if you want the old silence.
281
+
186
282
  ## DevTools
187
283
 
188
284
  ```tsx
@@ -288,6 +384,51 @@ fieldRegistry.register('text', ({ value, onValueChange, error, disabled }) => (
288
384
  ));
289
385
  ```
290
386
 
387
+ Forward `ariaInvalid`, `ariaRequired` and `ariaDescribedBy` too, and put
388
+ `makeErrorId(id)` on whatever element renders the message. `focusFirstInvalidField`
389
+ selects `[aria-invalid="true"]`, so a renderer that drops those props makes that
390
+ helper silently do nothing. See
391
+ [the recipes](../../docs/ui-kit-recipes.md#forward-the-aria-props).
392
+
393
+ ### Validation messages
394
+
395
+ Set the built-in validators' messages once per form instead of on every field:
396
+
397
+ ```tsx
398
+ const form = useDynamicForm({
399
+ fields,
400
+ messages: { required: 'Bắt buộc', minLength: 'Tối thiểu {min} ký tự' },
401
+ });
402
+ ```
403
+
404
+ A message passed straight to a validator still wins, and any key omitted falls
405
+ back to the English default. `setDefaultMessages(catalog)` sets a process-wide
406
+ one for code calling `validateFields` directly. Full key list in the
407
+ [core README](../core/README.md#validation-messages). **No locale bundles
408
+ ship** — the mechanism is here, the translations are yours.
409
+
410
+ ### Async options
411
+
412
+ `options` may return a promise, and the renderer receives `optionsStatus`
413
+ (`'idle' | 'loading' | 'ready' | 'error'`), `optionsError` and `onOptionsQuery`:
414
+
415
+ ```tsx
416
+ {
417
+ name: 'assignee',
418
+ type: 'userPicker',
419
+ options: async (data, _rootData, ctx) =>
420
+ fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
421
+ .then((r) => r.json()),
422
+ optionsDeps: (data) => [data.team], // reload when this changes; default []
423
+ debounceMs: 300, // collapses rapid reloads into one fetch
424
+ }
425
+ ```
426
+
427
+ Superseded requests are aborted and out-of-order responses discarded, so the
428
+ list always reflects the newest request. Static and synchronous options are
429
+ untouched and never enter a loading state. See the
430
+ [core README](../core/README.md#async-options).
431
+
291
432
  ## Repeatable field groups
292
433
 
293
434
  A field with `fields` renders as a repeatable group: `data[name]` becomes an array of items, each shaped by the nested `fields`, with "Add"/"Remove" controls rendered automatically.