@dynamic-field-kit/react 1.5.0 → 1.6.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,195 @@
1
1
  # @dynamic-field-kit/react
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - 67e4eec: Give every adapter one renderer-prop contract, unique field ids, and a touched
9
+ state the form store can actually drive.
10
+
11
+ Five things went wrong at once for anyone building a real form on 1.5.1, and
12
+ four of them share a cause: `FieldRendererProps` was a type nobody enforced.
13
+ Each adapter hand-wrote the object it handed the registered renderer, and the
14
+ three lists drifted. React dropped `placeholder`, `min`, `max`, `step`,
15
+ `accept` and `multiple`. Vue dropped `required`, `id`, `dirty` and the aria
16
+ flags. Angular dropped `touched`, `dirty` and `id` — so an Angular renderer had
17
+ no way to know whether a field had been touched, and "only show the error once
18
+ the user leaves the field" had to be rebuilt by hand. Setting
19
+ `placeholder` on a `FieldDescription` therefore did nothing at all on React and
20
+ Vue: no error, no warning, the value simply vanished. Core now owns the list as
21
+ `FIELD_RENDERER_PROP_KEYS` and builds the bag once in
22
+ `buildFieldRendererProps`, which all three adapters call, and
23
+ `scripts/check-renderer-prop-parity.js` fails the build if an adapter stops
24
+ forwarding one. The single deliberate deviation is Vue's `class` in place of
25
+ `className`: forwarding `className` lets it fall through to a renderer's root
26
+ element, where Vue assigns `el.className` and wipes the class the renderer set
27
+ on itself.
28
+
29
+ Field ids were `dfk-field-${name}`, derived from the field name alone. Two
30
+ forms holding a field of the same name — a create form beside an edit form, the
31
+ most ordinary layout there is — emitted the same DOM id twice, which is invalid
32
+ HTML and leaves every `label[for]` pointing at two inputs. Ids are now
33
+ namespaced per `MultiFieldInput` instance (React `useId`, so it is SSR-safe;
34
+ Vue's instance uid; a counter on Angular). Set `idPrefix` to pin them —
35
+ `idPrefix="dfk-field"` reproduces the old ids exactly — or give a single field
36
+ its own id with the new `FieldDescription.id`.
37
+
38
+ Touched state had two independent trackers that never met: the one in
39
+ `useDynamicForm`, and a private one inside `MultiFieldInput` that only blur
40
+ could set and that was the one renderers actually saw. So
41
+ `setFieldTouched` in an `onInvalid` handler changed nothing visible, submitting
42
+ a form nobody had focused showed no errors at all (the button looked broken),
43
+ and `reset()` could not clear the touched state a previous submit had left
44
+ behind. `MultiFieldInput` now accepts `touched` as a controlled prop —
45
+ `useDynamicForm` becomes the single source of truth for it, exactly as
46
+ `properties`/`onChange` already were for data — plus `onTouchedChange`, and a
47
+ `form` shorthand (React and Vue) that wires data, change, blur and touched in
48
+ one prop. `handleSubmit` marks every field touched before validating, and the
49
+ new `touchAll()`/`resetTouched()` sit alongside it. Omit `touched` and the old
50
+ internal tracker still runs, so nothing breaks; for that mode a ref
51
+ (`resetTouched()` on React and Vue, a public method on Angular) can clear it
52
+ without remounting the component.
53
+
54
+ The only behaviour change to watch for is the generated ids. Anything pinned to
55
+ a literal `dfk-field-*` id in CSS or a test needs either `idPrefix="dfk-field"`
56
+ or a per-field `id`. Angular's `MultiFieldInput` also loses four undocumented
57
+ template helpers — `getResolvedOptions`, `getDisabled`, `getReadOnly` and
58
+ `getError` — which its own template no longer calls now that `FieldInput`
59
+ resolves all of it through core. Keeping a second copy of that logic beside the
60
+ shared one is how the adapters drifted apart to begin with; the equivalents are
61
+ `resolveOptions`, `resolveDisabled`, `resolveReadOnly` and `validateField`,
62
+ already re-exported from this package.
63
+
64
+ Form validity now reflects current data immediately instead of merely checking
65
+ the lazily populated `errors` map. The error map remains lazy for display, and
66
+ passing a form binding (or the new controlled `errors` input) makes that same
67
+ map the renderer's source of truth, removing the previous timing mismatch.
68
+
69
+ Promise-based validators are no longer silently accepted on submit.
70
+ `validateFields` reports unresolved field names in `pending`; every framework
71
+ form helper uses one async-capable validation pass before dispatching submit
72
+ callbacks. React, Vue and Angular also expose
73
+ `validateAsync()` for explicit pre-submit checks. Live `isValid` remains a
74
+ synchronous answer because a property/computed/signal cannot await.
75
+
76
+ The new UI-kit recipes show complete touched/error wiring for Ant Design,
77
+ Vuetify and Angular Material.
78
+
79
+ - 5e0b08f: Make async validation answerable: a status you can act on, runs that cancel
80
+ cleanly, and touched state that reaches inside repeatable groups.
81
+
82
+ `ValidationResult` gains `complete` and `status` (`'valid' | 'invalid' |
83
+ 'pending'`). Combining `valid` with `pending` was the only way to tell "nothing
84
+ is wrong" from "nothing is wrong _yet_", and everyone got it wrong the same
85
+ way — a `valid: true` with async rules still in flight reads as a green light.
86
+ `status` is the single answer; `complete` says whether every applicable
87
+ validator finished. Both are always present on a result the library returns, so
88
+ reading them needs no fallback; code that constructs a `ValidationResult` by
89
+ hand (a mock, a wrapper typed to return one) has to supply them.
90
+
91
+ `FieldDescription.validationMode: 'async'` declares a validator that returns a
92
+ Promise without the `async` keyword, which detection cannot see. Declaring it
93
+ keeps the synchronous pass from invoking the validator at all — and, unlike
94
+ detection, it is an explicit opt-in, so the dev warning about a field the live
95
+ pass cannot check stays quiet for it.
96
+
97
+ `validateFieldsAsync` now takes a `ValidationContext` and forwards its
98
+ `AbortSignal` to every validator, runs independent validators in parallel
99
+ instead of awaiting them one after another, skips validators once the signal is
100
+ aborted, and reports an aborted run as `complete: false` / `status: 'pending'`.
101
+ A validator that honours the signal the conventional way — rejecting with an
102
+ `AbortError` — no longer rejects the caller's `handleSubmit`; an error that is
103
+ not an abort still propagates.
104
+
105
+ Each adapter's form helper exposes `isValidating`, `isValidationComplete` and
106
+ `validationStatus`, and applies latest-run-wins: typing cancels an in-flight
107
+ live validation so a stale result cannot overwrite a newer one. A submit is not
108
+ collateral damage of that — it validates the snapshot the user submitted under
109
+ a controller of its own, so editing a field mid-flight no longer leaves the
110
+ form with the submit silently dropped, no `onValid`/`onInvalid`, and a button
111
+ that just re-enables.
112
+
113
+ `touchAll()` now expands to the concrete leaf paths that exist in the data
114
+ (`contacts[0].email`, not `contacts`) via the new `collectFieldPaths`, skipping
115
+ fields validation itself skips — hidden by `appearCondition`, or disabled.
116
+ Repeatable group items receive `touched` and report blur with their full path,
117
+ so a UI kit that only shows an error once a field is touched now works inside a
118
+ group. An item with no touched keys still receives a map rather than
119
+ `undefined`, which previously flipped the nested input into tracking touched by
120
+ itself and left it stale after the owner cleared the map. The new
121
+ `indexGroupPathMap` is what indexes those maps by item, exported so a custom
122
+ renderer can do the same without filtering the whole map per item.
123
+
124
+ React's `isValid` is now seeded from the initial data instead of from an
125
+ effect. An effect never runs on the server, so a server-rendered form shipped
126
+ `isValid: true` for an empty required field and never corrected it — a submit
127
+ button rendered enabled and stayed that way.
128
+
129
+ `@dynamic-field-kit/angular`'s `types` entry pointed at `dist/index.d.ts`,
130
+ which is not where its type declarations are emitted any more; it and the
131
+ `exports` block now point at the file that actually ships, so TypeScript
132
+ consumers resolve the package's types again.
133
+
134
+ - eec9386: Correct the peer ranges to the ones that actually work, and prove both ends of
135
+ each in CI.
136
+
137
+ `@dynamic-field-kit/angular` declared `@angular/core` and `@angular/common` as
138
+ `>=14 <22`, but the form store is built on `signal` and `computed`, which
139
+ Angular introduced in **16**. On 14 or 15 npm accepted the install and the
140
+ package then failed on import - the manifest promised something it could not
141
+ do. The range is now `>=16 <22`, so the same install is refused up front.
142
+
143
+ `@dynamic-field-kit/vue` moves from `vue ^3.0.0` to `^3.2.0`.
144
+ `useDynamicForm` now aborts an in-flight validation when the owning effect
145
+ scope is disposed, using `getCurrentScope` / `onScopeDispose` - both Vue 3.2.
146
+ Without this an unmounted form held its request open until the response came
147
+ back. If you are on Vue 3.0 or 3.1, stay on 1.5.x; nothing else in the package
148
+ ever required 3.2, but nothing tested below it either.
149
+
150
+ Both ranges are now verified rather than asserted:
151
+ `scripts/verify-vue-peer-range.js` server-renders the packed tarballs under Vue
152
+ 3.2 and the newest 3.x, and `scripts/verify-angular-peer-range.js` installs
153
+ them against Angular 16 and 21 and checks the package imports, its components
154
+ evaluate and it shares one registry with core. Both run in the CI verify job,
155
+ next to the React one that has existed since 1.5.0. A render is out of reach
156
+ for Angular - the published fesm2022 needs the CLI's linker to instantiate a
157
+ component - but import-and-wire is the level that breaks across majors, which
158
+ is exactly how a floor of 14 survived years of `signal()`.
159
+
160
+ The three adapters now re-export `collectFieldPaths`, `indexGroupPathMap` and
161
+ the `ValidationContext` type from core, so typing a validator's `context`
162
+ argument no longer means importing `@dynamic-field-kit/core` alongside the
163
+ adapter.
164
+
165
+ `@angular/platform-browser-dynamic`, which Angular 21 deprecates, is gone from
166
+ the package's devDependencies and from the demo app, which never used it - the
167
+ test setup now initialises through `@angular/platform-browser/testing`.
168
+
169
+ ## 1.5.1
170
+
171
+ ### Patch Changes
172
+
173
+ - Findable on npm: every package now carries real search keywords, and homepage points at the live demo instead of the README the npm page already renders.
174
+ - b22a6a1: Make the packages findable on npm, and point `homepage` at something worth
175
+ landing on.
176
+
177
+ The keyword lists were three entries long and two of those were the package's
178
+ own name — nobody searches `dynamic-field-kit/core`. npm ranks search partly on
179
+ keywords, so in practice these packages could only be found by someone who
180
+ already knew what they were called. The repository has carried the right
181
+ vocabulary as GitHub topics all along (`dynamic-forms`, `form-builder`,
182
+ `form-engine`, `form-validation`, `schema-driven`, `headless`, and the three
183
+ framework names); npm simply never saw any of it. Each package now carries that
184
+ vocabulary plus the terms its own users would type, including the schema
185
+ libraries it actually adapts — zod, yup, valibot and Standard Schema. Not JSON
186
+ Schema, which it does not support.
187
+
188
+ `homepage` pointed at the package's README on GitHub, which is the same text
189
+ npm already renders on the package page from the shipped README. It now points
190
+ at the live demo instead, where the forms actually run. The source stays one
191
+ click away in `repository`.
192
+
3
193
  ## 1.5.0
4
194
 
5
195
  ### Minor Changes
package/README.md CHANGED
@@ -41,16 +41,27 @@ 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
44
46
  - `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options
45
47
  - `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
48
+ - `ValidationResult` / `ValidationContext`
49
+
50
+ `useDynamicForm` keeps live validation synchronous - a validator declared or
51
+ detected as async is never invoked on that path. Its `handleSubmit` runs one
52
+ async-capable pass, and `validateAsync()` is there when you need that answer
53
+ before submit. Runs are latest-wins: typing aborts the live run in flight, so a
54
+ stale result cannot overwrite a newer one, and a submit validates the snapshot
55
+ it was given under a controller of its own, so editing mid-submit no longer
56
+ cancels it. Declare a Promise-returning validator with
57
+ `validationMode: 'async'` and read `context.signal` (the fourth argument) to
58
+ cancel the request itself. See the
51
59
  [core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation)
52
60
  for the full rules.
53
61
 
62
+ For a complete UI integration, see the
63
+ [Ant Design recipe](../../docs/ui-kit-recipes.md#react--ant-design).
64
+
54
65
  `FieldGroupInput` (repeatable field groups) is used internally by `FieldInput` and doesn't need to be imported directly - see "Repeatable field groups" below.
55
66
 
56
67
  Default layouts are registered automatically when you import the package root.
@@ -132,36 +143,87 @@ const form = useDynamicForm({
132
143
  });
133
144
 
134
145
  <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
- />
146
+ <MultiFieldInput fieldDescriptions={fields} form={form} />
141
147
  <button disabled={form.isSubmitting}>
142
148
  {form.isSubmitting ? 'Saving…' : 'Save'}
143
149
  </button>
144
150
  </form>;
145
151
  ```
146
152
 
153
+ `form` is shorthand for five state/callback props, and is the recommended wiring:
154
+
155
+ ```tsx
156
+ <MultiFieldInput
157
+ fieldDescriptions={fields}
158
+ properties={form.data}
159
+ onChange={form.handleChange}
160
+ onBlurField={form.handleBlur} // touched + validateOnBlur
161
+ touched={form.touched} // makes the hook the only source of truth
162
+ errors={form.errors} // renderer and hook read the same error map
163
+ />
164
+ ```
165
+
166
+ Passing `touched` and `errors` gives the form store ownership of renderer
167
+ metadata. `touched` is what makes an invalid submit visible: `handleSubmit`
168
+ marks every field touched before validating, so a renderer that gates its error
169
+ on `touched` shows it even for fields the user never focused. `reset()` clears
170
+ touched the same way. Individually passed props win over the ones `form`
171
+ derives, so you can pass `form` and still override one wire.
172
+
147
173
  | Member | Description |
148
174
  | ----------------------------------- | --------------------------------------------------------------------------------- |
149
175
  | `data` | Current form data, with `computeValue` fields applied |
150
176
  | `errors` | `Record<string, string[]>`, keyed like `validateFields` |
151
- | `isValid` / `isDirty` | No errors recorded / any value has changed |
177
+ | `isValid` / `isDirty` | Current synchronous validity / any value has changed |
178
+ | `isValidating` | An async validation pass is in flight |
179
+ | `isValidationComplete` | Every applicable validator finished and none is in flight |
180
+ | `validationStatus` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" |
152
181
  | `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted |
153
182
  | `touched` | Fields that have been blurred |
154
183
  | `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` |
155
184
  | `setFieldValue(name, value)` | Change one field |
156
185
  | `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
157
186
  | `setFieldTouched(name, value?)` | Set touched explicitly |
187
+ | `touchAll()` | Mark every field touched — `handleSubmit` already calls it |
188
+ | `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
189
+ | `setTouched` | Raw setter for the whole touched map |
158
190
  | `setData` | Raw state setter, for escape hatches |
159
191
  | `validate()` | Validate now, returns a boolean |
192
+ | `validateAsync()` | Validate now, awaiting Promise-based rules |
160
193
  | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
161
194
  | `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches |
162
195
 
163
- `MultiFieldInput` tracks touched internally regardless; `onBlurField` is the
164
- hook for driving an external store like this one.
196
+ Leave `touched` off and `MultiFieldInput` falls back to tracking it internally
197
+ from blur alone, as it always did. In that mode nothing outside the component
198
+ can clear it — a form that stays mounted across submits will keep showing the
199
+ errors of the previous round after `reset()` — so it exposes a ref for it:
200
+
201
+ ```tsx
202
+ const ref = useRef<MultiFieldInputHandle>(null);
203
+
204
+ <MultiFieldInput ref={ref} fieldDescriptions={fields} />;
205
+ // after a successful submit
206
+ ref.current?.resetTouched();
207
+ ```
208
+
209
+ `resetTouched()`, `setFieldTouched(name, value?)` and `getTouched()` are the
210
+ handle's members. Controlled mode needs none of them: `form.reset()` covers it.
211
+
212
+ ## Field ids
213
+
214
+ Each field renders with `id={`${idPrefix}-${name}`}`, where `idPrefix` defaults
215
+ to a value unique to the `MultiFieldInput` instance (from `useId`, so server and
216
+ client agree). Two forms containing a field of the same name therefore no longer
217
+ emit the same DOM id twice.
218
+
219
+ ```tsx
220
+ // pinned ids — reproduces the pre-1.6 `dfk-field-title`
221
+ <MultiFieldInput fieldDescriptions={fields} idPrefix="dfk-field" />
222
+ ```
223
+
224
+ For one field, set `id` on its `FieldDescription`; it wins over the prefix.
225
+ Renderers receive the resolved value as the `id` prop, so a `<label htmlFor>` in
226
+ a renderer points at exactly one input.
165
227
 
166
228
  ## Default renderers
167
229
 
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import React, { ReactNode, ComponentType } from 'react';
2
2
  import { FieldTypeKey, Properties, FieldDescription, LayoutConfig, ValidationResult, FieldRendererProps, FieldTypeMap } from '@dynamic-field-kit/core';
3
- export { FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, LayoutConfig, ValidationResult, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
3
+ export { FIELD_RENDERER_PROP_KEYS, FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, LayoutConfig, ValidationContext, ValidationResult, buildFieldRendererProps, collectFieldPaths, indexGroupPathMap, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
4
4
 
5
5
  type LayoutRenderer<C = unknown> = (props: {
6
6
  children: React.ReactNode;
@@ -19,6 +19,7 @@ interface Props$2<T extends FieldTypeKey> {
19
19
  onChange?: (value: unknown) => void;
20
20
  onBlur?: () => void;
21
21
  label?: string;
22
+ placeholder?: string;
22
23
  options?: Properties[];
23
24
  className?: string;
24
25
  description?: ReactNode;
@@ -32,28 +33,68 @@ interface Props$2<T extends FieldTypeKey> {
32
33
  ariaInvalid?: boolean;
33
34
  ariaDescribedBy?: string;
34
35
  ariaRequired?: boolean;
36
+ min?: number | string;
37
+ max?: number | string;
38
+ step?: number | string;
39
+ accept?: string;
40
+ multiple?: boolean;
35
41
  /** Extra, framework-agnostic props forwarded verbatim to the renderer. */
36
42
  extraProps?: Properties;
37
43
  }
38
- declare const DynamicInputInner: <T extends FieldTypeKey>({ type, value, onChange, onBlur, label, options, className, description, disabled, readOnly, required, touched, dirty, error, id, ariaInvalid, ariaDescribedBy, ariaRequired, extraProps, }: Props$2<T>) => React.JSX.Element;
44
+ declare const DynamicInputInner: <T extends FieldTypeKey>({ type, onChange, onBlur, extraProps, ...rendererProps }: Props$2<T>) => React.JSX.Element;
39
45
  declare const DynamicInput: typeof DynamicInputInner;
40
46
 
41
47
  interface Props$1 {
42
48
  fieldDescription: FieldDescription;
43
49
  renderInfos: Properties;
44
50
  rootData?: Properties;
51
+ /** Per-form-instance id namespace; see core's `makeFieldId`. */
52
+ idPrefix?: string;
45
53
  touched?: boolean;
54
+ touchedMap?: Record<string, boolean>;
46
55
  dirty?: boolean;
56
+ errors?: Record<string, string[]>;
47
57
  onBlurField?: (key: string) => void;
48
58
  onValueChangeField: (value: unknown, key: string) => void;
49
59
  }
50
- declare const FieldInput: React.MemoExoticComponent<({ fieldDescription, renderInfos, rootData, touched, dirty, onBlurField, onValueChangeField, }: Props$1) => React.JSX.Element>;
60
+ declare const FieldInput: React.MemoExoticComponent<({ fieldDescription, renderInfos, rootData, idPrefix, touched, touchedMap, dirty, errors, onBlurField, onValueChangeField, }: Props$1) => React.JSX.Element>;
51
61
 
62
+ /**
63
+ * The slice of `useDynamicForm`'s result `MultiFieldInput` needs to drive
64
+ * itself. Structural, so the hook result can be passed straight in.
65
+ */
66
+ interface DynamicFormBinding {
67
+ data: Properties;
68
+ errors: Record<string, string[]>;
69
+ touched: Record<string, boolean>;
70
+ handleChange: (data: Properties) => void;
71
+ handleBlur: (fieldName: string) => void;
72
+ }
73
+ /** Imperative handle exposed on a `MultiFieldInput` ref. */
74
+ interface MultiFieldInputHandle {
75
+ /**
76
+ * Clears the internally tracked touched state. Only meaningful in
77
+ * uncontrolled mode - when `touched` is passed as a prop, resetting the form
78
+ * store (e.g. `useDynamicForm().reset()`) already clears it.
79
+ */
80
+ resetTouched: () => void;
81
+ setFieldTouched: (fieldName: string, isTouched?: boolean) => void;
82
+ /** The touched map currently in effect, controlled or internal. */
83
+ getTouched: () => Record<string, boolean>;
84
+ }
52
85
  interface Props {
53
86
  fieldDescriptions: FieldDescription[];
54
87
  properties?: Properties;
55
88
  onChange?: (data: Properties) => void;
56
89
  layout?: LayoutConfig;
90
+ /**
91
+ * Namespace for generated field ids: a field renders with
92
+ * `${idPrefix}-${name}`. Defaults to a value unique to this component
93
+ * instance, so two forms containing the same field name do not emit
94
+ * duplicate DOM ids. Pass a fixed string to pin ids (`idPrefix="dfk-field"`
95
+ * restores the pre-1.6 ids), or set `FieldDescription.id` per field.
96
+ */
97
+ idPrefix?: string;
57
98
  /**
58
99
  * Top-level form data, threaded down through repeatable groups so a nested
59
100
  * field's `appearCondition`/`computeValue` can read the root form. Omitted at
@@ -73,8 +114,26 @@ interface Props {
73
114
  * map and `validateOnBlur` behaviour.
74
115
  */
75
116
  onBlurField?: (fieldName: string) => void;
117
+ /**
118
+ * Controlled touched map. When provided it is the single source of truth and
119
+ * the internal tracker is bypassed entirely, so `useDynamicForm().touched`
120
+ * (updated by `setFieldTouched`, `touchAll`, `handleSubmit` and cleared by
121
+ * `reset`) is what renderers actually see. Omit it to keep the internal,
122
+ * blur-only tracker.
123
+ */
124
+ touched?: Record<string, boolean>;
125
+ /** Controlled validation errors. Empty means no renderer error. */
126
+ errors?: Record<string, string[]>;
127
+ /** Fires with the next touched map whenever a field is blurred. */
128
+ onTouchedChange?: (touched: Record<string, boolean>) => void;
129
+ /**
130
+ * Shorthand that wires `properties`, `onChange`, `onBlurField` and `touched`
131
+ * from a `useDynamicForm` result in one prop. Individually passed props win
132
+ * over the ones derived from here.
133
+ */
134
+ form?: DynamicFormBinding;
76
135
  }
77
- declare const MultiFieldInput: ({ fieldDescriptions, properties, onChange, layout, rootData, onValidityChange, onBlurField, }: Props) => React.JSX.Element;
136
+ declare const MultiFieldInput: React.ForwardRefExoticComponent<Props & React.RefAttributes<MultiFieldInputHandle>>;
78
137
 
79
138
  interface DynamicFormDevToolsProps {
80
139
  data: Properties;
@@ -96,6 +155,9 @@ interface UseDynamicFormResult {
96
155
  data: Properties;
97
156
  errors: Record<string, string[]>;
98
157
  isValid: boolean;
158
+ isValidating: boolean;
159
+ isValidationComplete: boolean;
160
+ validationStatus: ValidationResult['status'];
99
161
  isDirty: boolean;
100
162
  isSubmitting: boolean;
101
163
  isSubmitted: boolean;
@@ -103,10 +165,22 @@ interface UseDynamicFormResult {
103
165
  setData: React.Dispatch<React.SetStateAction<Properties>>;
104
166
  setFieldValue: (name: string, value: unknown) => void;
105
167
  setFieldTouched: (name: string, isTouched?: boolean) => void;
168
+ /** Replaces the whole touched map. */
169
+ setTouched: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
170
+ /**
171
+ * Marks every field touched at once. `handleSubmit` calls this for you, so
172
+ * an invalid submit surfaces errors on fields the user never focused - pass
173
+ * `touched` into `MultiFieldInput` for it to take effect.
174
+ */
175
+ touchAll: () => void;
176
+ /** Clears the touched map without touching data, errors or dirty state. */
177
+ resetTouched: () => void;
106
178
  handleChange: (newData: Properties) => void;
107
179
  handleBlur: (fieldName: string) => void;
108
180
  reset: (newValues?: Properties) => void;
109
181
  validate: () => boolean;
182
+ /** Validate all fields and await Promise-based rules. */
183
+ validateAsync: () => Promise<boolean>;
110
184
  handleSubmit: (onValid: (data: Properties) => void | Promise<void>, onInvalid?: (errors: Record<string, string[]>) => void) => (e?: React.FormEvent) => Promise<void>;
111
185
  }
112
186
  declare function useDynamicForm({ fields, initialValues, validateOnBlur, validateOnChange, }: UseDynamicFormOptions): UseDynamicFormResult;
@@ -129,4 +203,4 @@ declare const FieldRegistryProvider: ({ registry, children, }: FieldRegistryProv
129
203
  /** The registry for the nearest provider, or the global singleton. */
130
204
  declare function useFieldRegistry(): ReactFieldRegistry;
131
205
 
132
- export { DynamicFormDevTools, DynamicInput, FieldInput, FieldRegistryProvider, type FieldRegistryProviderProps, MultiFieldInput, type ReactFieldRegistry, type ReactFieldRenderer, defaultRenderersMap, fieldRegistry, getDefaultRenderer, layoutRegistry, useDynamicForm, useFieldRegistry };
206
+ export { type DynamicFormBinding, DynamicFormDevTools, DynamicInput, FieldInput, FieldRegistryProvider, type FieldRegistryProviderProps, MultiFieldInput, type MultiFieldInputHandle, type ReactFieldRegistry, type ReactFieldRenderer, type UseDynamicFormOptions, type UseDynamicFormResult, defaultRenderersMap, fieldRegistry, getDefaultRenderer, layoutRegistry, useDynamicForm, useFieldRegistry };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import React, { ReactNode, ComponentType } from 'react';
2
2
  import { FieldTypeKey, Properties, FieldDescription, LayoutConfig, ValidationResult, FieldRendererProps, FieldTypeMap } from '@dynamic-field-kit/core';
3
- export { FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, LayoutConfig, ValidationResult, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
3
+ export { FIELD_RENDERER_PROP_KEYS, FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, LayoutConfig, ValidationContext, ValidationResult, buildFieldRendererProps, collectFieldPaths, indexGroupPathMap, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
4
4
 
5
5
  type LayoutRenderer<C = unknown> = (props: {
6
6
  children: React.ReactNode;
@@ -19,6 +19,7 @@ interface Props$2<T extends FieldTypeKey> {
19
19
  onChange?: (value: unknown) => void;
20
20
  onBlur?: () => void;
21
21
  label?: string;
22
+ placeholder?: string;
22
23
  options?: Properties[];
23
24
  className?: string;
24
25
  description?: ReactNode;
@@ -32,28 +33,68 @@ interface Props$2<T extends FieldTypeKey> {
32
33
  ariaInvalid?: boolean;
33
34
  ariaDescribedBy?: string;
34
35
  ariaRequired?: boolean;
36
+ min?: number | string;
37
+ max?: number | string;
38
+ step?: number | string;
39
+ accept?: string;
40
+ multiple?: boolean;
35
41
  /** Extra, framework-agnostic props forwarded verbatim to the renderer. */
36
42
  extraProps?: Properties;
37
43
  }
38
- declare const DynamicInputInner: <T extends FieldTypeKey>({ type, value, onChange, onBlur, label, options, className, description, disabled, readOnly, required, touched, dirty, error, id, ariaInvalid, ariaDescribedBy, ariaRequired, extraProps, }: Props$2<T>) => React.JSX.Element;
44
+ declare const DynamicInputInner: <T extends FieldTypeKey>({ type, onChange, onBlur, extraProps, ...rendererProps }: Props$2<T>) => React.JSX.Element;
39
45
  declare const DynamicInput: typeof DynamicInputInner;
40
46
 
41
47
  interface Props$1 {
42
48
  fieldDescription: FieldDescription;
43
49
  renderInfos: Properties;
44
50
  rootData?: Properties;
51
+ /** Per-form-instance id namespace; see core's `makeFieldId`. */
52
+ idPrefix?: string;
45
53
  touched?: boolean;
54
+ touchedMap?: Record<string, boolean>;
46
55
  dirty?: boolean;
56
+ errors?: Record<string, string[]>;
47
57
  onBlurField?: (key: string) => void;
48
58
  onValueChangeField: (value: unknown, key: string) => void;
49
59
  }
50
- declare const FieldInput: React.MemoExoticComponent<({ fieldDescription, renderInfos, rootData, touched, dirty, onBlurField, onValueChangeField, }: Props$1) => React.JSX.Element>;
60
+ declare const FieldInput: React.MemoExoticComponent<({ fieldDescription, renderInfos, rootData, idPrefix, touched, touchedMap, dirty, errors, onBlurField, onValueChangeField, }: Props$1) => React.JSX.Element>;
51
61
 
62
+ /**
63
+ * The slice of `useDynamicForm`'s result `MultiFieldInput` needs to drive
64
+ * itself. Structural, so the hook result can be passed straight in.
65
+ */
66
+ interface DynamicFormBinding {
67
+ data: Properties;
68
+ errors: Record<string, string[]>;
69
+ touched: Record<string, boolean>;
70
+ handleChange: (data: Properties) => void;
71
+ handleBlur: (fieldName: string) => void;
72
+ }
73
+ /** Imperative handle exposed on a `MultiFieldInput` ref. */
74
+ interface MultiFieldInputHandle {
75
+ /**
76
+ * Clears the internally tracked touched state. Only meaningful in
77
+ * uncontrolled mode - when `touched` is passed as a prop, resetting the form
78
+ * store (e.g. `useDynamicForm().reset()`) already clears it.
79
+ */
80
+ resetTouched: () => void;
81
+ setFieldTouched: (fieldName: string, isTouched?: boolean) => void;
82
+ /** The touched map currently in effect, controlled or internal. */
83
+ getTouched: () => Record<string, boolean>;
84
+ }
52
85
  interface Props {
53
86
  fieldDescriptions: FieldDescription[];
54
87
  properties?: Properties;
55
88
  onChange?: (data: Properties) => void;
56
89
  layout?: LayoutConfig;
90
+ /**
91
+ * Namespace for generated field ids: a field renders with
92
+ * `${idPrefix}-${name}`. Defaults to a value unique to this component
93
+ * instance, so two forms containing the same field name do not emit
94
+ * duplicate DOM ids. Pass a fixed string to pin ids (`idPrefix="dfk-field"`
95
+ * restores the pre-1.6 ids), or set `FieldDescription.id` per field.
96
+ */
97
+ idPrefix?: string;
57
98
  /**
58
99
  * Top-level form data, threaded down through repeatable groups so a nested
59
100
  * field's `appearCondition`/`computeValue` can read the root form. Omitted at
@@ -73,8 +114,26 @@ interface Props {
73
114
  * map and `validateOnBlur` behaviour.
74
115
  */
75
116
  onBlurField?: (fieldName: string) => void;
117
+ /**
118
+ * Controlled touched map. When provided it is the single source of truth and
119
+ * the internal tracker is bypassed entirely, so `useDynamicForm().touched`
120
+ * (updated by `setFieldTouched`, `touchAll`, `handleSubmit` and cleared by
121
+ * `reset`) is what renderers actually see. Omit it to keep the internal,
122
+ * blur-only tracker.
123
+ */
124
+ touched?: Record<string, boolean>;
125
+ /** Controlled validation errors. Empty means no renderer error. */
126
+ errors?: Record<string, string[]>;
127
+ /** Fires with the next touched map whenever a field is blurred. */
128
+ onTouchedChange?: (touched: Record<string, boolean>) => void;
129
+ /**
130
+ * Shorthand that wires `properties`, `onChange`, `onBlurField` and `touched`
131
+ * from a `useDynamicForm` result in one prop. Individually passed props win
132
+ * over the ones derived from here.
133
+ */
134
+ form?: DynamicFormBinding;
76
135
  }
77
- declare const MultiFieldInput: ({ fieldDescriptions, properties, onChange, layout, rootData, onValidityChange, onBlurField, }: Props) => React.JSX.Element;
136
+ declare const MultiFieldInput: React.ForwardRefExoticComponent<Props & React.RefAttributes<MultiFieldInputHandle>>;
78
137
 
79
138
  interface DynamicFormDevToolsProps {
80
139
  data: Properties;
@@ -96,6 +155,9 @@ interface UseDynamicFormResult {
96
155
  data: Properties;
97
156
  errors: Record<string, string[]>;
98
157
  isValid: boolean;
158
+ isValidating: boolean;
159
+ isValidationComplete: boolean;
160
+ validationStatus: ValidationResult['status'];
99
161
  isDirty: boolean;
100
162
  isSubmitting: boolean;
101
163
  isSubmitted: boolean;
@@ -103,10 +165,22 @@ interface UseDynamicFormResult {
103
165
  setData: React.Dispatch<React.SetStateAction<Properties>>;
104
166
  setFieldValue: (name: string, value: unknown) => void;
105
167
  setFieldTouched: (name: string, isTouched?: boolean) => void;
168
+ /** Replaces the whole touched map. */
169
+ setTouched: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
170
+ /**
171
+ * Marks every field touched at once. `handleSubmit` calls this for you, so
172
+ * an invalid submit surfaces errors on fields the user never focused - pass
173
+ * `touched` into `MultiFieldInput` for it to take effect.
174
+ */
175
+ touchAll: () => void;
176
+ /** Clears the touched map without touching data, errors or dirty state. */
177
+ resetTouched: () => void;
106
178
  handleChange: (newData: Properties) => void;
107
179
  handleBlur: (fieldName: string) => void;
108
180
  reset: (newValues?: Properties) => void;
109
181
  validate: () => boolean;
182
+ /** Validate all fields and await Promise-based rules. */
183
+ validateAsync: () => Promise<boolean>;
110
184
  handleSubmit: (onValid: (data: Properties) => void | Promise<void>, onInvalid?: (errors: Record<string, string[]>) => void) => (e?: React.FormEvent) => Promise<void>;
111
185
  }
112
186
  declare function useDynamicForm({ fields, initialValues, validateOnBlur, validateOnChange, }: UseDynamicFormOptions): UseDynamicFormResult;
@@ -129,4 +203,4 @@ declare const FieldRegistryProvider: ({ registry, children, }: FieldRegistryProv
129
203
  /** The registry for the nearest provider, or the global singleton. */
130
204
  declare function useFieldRegistry(): ReactFieldRegistry;
131
205
 
132
- export { DynamicFormDevTools, DynamicInput, FieldInput, FieldRegistryProvider, type FieldRegistryProviderProps, MultiFieldInput, type ReactFieldRegistry, type ReactFieldRenderer, defaultRenderersMap, fieldRegistry, getDefaultRenderer, layoutRegistry, useDynamicForm, useFieldRegistry };
206
+ export { type DynamicFormBinding, DynamicFormDevTools, DynamicInput, FieldInput, FieldRegistryProvider, type FieldRegistryProviderProps, MultiFieldInput, type MultiFieldInputHandle, type ReactFieldRegistry, type ReactFieldRenderer, type UseDynamicFormOptions, type UseDynamicFormResult, defaultRenderersMap, fieldRegistry, getDefaultRenderer, layoutRegistry, useDynamicForm, useFieldRegistry };