@dynamic-field-kit/angular 1.6.0 → 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,63 @@
1
1
  # @dynamic-field-kit/angular
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
+
3
61
  ## 1.6.0
4
62
 
5
63
  ### Minor Changes
package/README.md CHANGED
@@ -41,7 +41,7 @@ npm install @dynamic-field-kit/core@^1.5.0 @dynamic-field-kit/angular@^1.5.0
41
41
  `dirty`, `id` and the aria flags used to be missing here, which left an
42
42
  Angular renderer no way to tell whether a field had been touched
43
43
  - `DynamicFormOptions` — what `createDynamicFormStore` takes: `fields`,
44
- `initialValues`, `validateOnBlur`, `validateOnChange`
44
+ `initialValues`, `validateOnBlur`, `validateOnChange`, `messages`
45
45
  - `LayoutConfig` / `ColumnLayoutConfig` / `RowLayoutConfig` /
46
46
  `GridLayoutConfig` — the layout config types, re-exported from core
47
47
  - `BaseLayoutConfig` / `ResponsiveLayoutConfig` — this adapter's historical
@@ -59,7 +59,15 @@ both packages:
59
59
  - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …)
60
60
  - `FieldDescription` / `FieldTypeKey` / `FieldRendererProps` — the schema and
61
61
  renderer contracts every adapter shares
62
- - `ValidationResult` / `ValidationContext`
62
+ - `ValidationResult` / `ValidationContext` — the context carries `signal` and the
63
+ optional `t` message resolver
64
+ - `buildFieldRendererProps` / `makeFieldId` / `makeErrorId` /
65
+ `FIELD_RENDERER_PROP_KEYS` — the renderer prop contract. `makeErrorId(id)` is
66
+ what a custom renderer puts on its message element so `aria-describedby`
67
+ resolves
68
+ - `createOptionsLoader` / `isAsyncOptions` — the async options engine
69
+ - `createMessageResolver` / `setDefaultMessages` / `MessageCatalog` — validation
70
+ message catalog
63
71
 
64
72
  `createDynamicFormStore` keeps live validation synchronous - a validator declared or
65
73
  detected as async is never invoked on that path. Its `handleSubmit` runs one
@@ -170,6 +178,7 @@ import {
170
178
  [errors]="store.errors()"
171
179
  (onChange)="store.handleChange($event)"
172
180
  (onBlurField)="store.handleBlur($event)"
181
+ [initialProperties]="store.baselineValues()"
173
182
  ></dfk-multi-field-input>
174
183
  <button [disabled]="store.isSubmitting()">Save</button>
175
184
  </form>
@@ -181,6 +190,7 @@ export class MyForm {
181
190
  fields,
182
191
  initialValues: { country: 'VN' },
183
192
  validateOnBlur: true, // default
193
+ messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions
184
194
  });
185
195
 
186
196
  // handleSubmit returns a handler, exactly like React and Vue.
@@ -188,26 +198,28 @@ export class MyForm {
188
198
  }
189
199
  ```
190
200
 
191
- | Member | Description |
192
- | ----------------------------------- | --------------------------------------------------------------------------------- |
193
- | `data()` | Current form data, with `computeValue` fields applied |
194
- | `errors()` | `Record<string, string[]>`, keyed like `validateFields` |
195
- | `isValid()` / `isDirty()` | Current synchronous validity / any value has changed |
196
- | `isValidating()` | An async validation pass is in flight |
197
- | `isValidationComplete()` | Every applicable validator finished and none is in flight |
198
- | `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" |
199
- | `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted |
200
- | `touched()` | Fields that have been blurred |
201
- | `handleChange(data)` | Replace the whole form data bind to `(onChange)` |
202
- | `setFieldValue(name, value)` | Change one field |
203
- | `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
204
- | `setFieldTouched(name, value?)` | Set touched explicitly |
205
- | `touchAll()` | Mark every field touched `handleSubmit` already calls it |
206
- | `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
207
- | `validate()` | Validate now, returns a boolean |
208
- | `validateAsync()` | Validate now, awaiting Promise-based rules |
209
- | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
210
- | `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches |
201
+ | Member | Description |
202
+ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
203
+ | `data()` | Current form data, with `computeValue` fields applied |
204
+ | `errors()` | `Record<string, string[]>`, keyed like `validateFields` |
205
+ | `isValid()` / `isDirty()` | Current synchronous validity / any value has changed |
206
+ | `baselineValues()` | Signal holding the values `dirty` is measured against - `initialValues` until `reset(newValues)` replaces them |
207
+ | `getDirtyValues()` | Only the entries differing from `baselineValues`, for PATCH-style submits |
208
+ | `isValidating()` | An async validation pass is in flight |
209
+ | `isValidationComplete()` | Every applicable validator finished and none is in flight |
210
+ | `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" |
211
+ | `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted |
212
+ | `touched()` | Fields that have been blurred |
213
+ | `handleChange(data)` | Replace the whole form data — bind to `(onChange)` |
214
+ | `setFieldValue(name, value)` | Change one field |
215
+ | `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
216
+ | `setFieldTouched(name, value?)` | Set touched explicitly |
217
+ | `touchAll()` | Mark every field touched — `handleSubmit` already calls it |
218
+ | `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
219
+ | `validate()` | Validate now, returns a boolean |
220
+ | `validateAsync()` | Validate now, awaiting Promise-based rules |
221
+ | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
222
+ | `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches |
211
223
 
212
224
  `MultiFieldInput` emits `(onBlurField)` with the field's name, driven by a
213
225
  `focusout` listener — so it works with any renderer, without the renderer
@@ -247,6 +259,13 @@ Any type you have not registered falls back to one of these. `file` emits a
247
259
  `File` (or `File[]` when `multiple` is set), `range` and `number` emit numbers,
248
260
  `checkbox` / `switch` emit booleans; everything else emits strings.
249
261
 
262
+ Since 1.7.0 a default renderer also renders its validation message, as
263
+ `<div id="{fieldId}-error" class="dfk-field-error" role="alert">`, which is what
264
+ `aria-describedby` points at. Before that they were handed `error` and dropped
265
+ it, so the form showed nothing. A registered custom renderer is unaffected — the
266
+ node is emitted only where a default was used, so you never get two copies. Hide
267
+ it with `.dfk-field-error { display: none }` if you want the old silence.
268
+
250
269
  ## DevTools
251
270
 
252
271
  ```html
@@ -339,6 +358,52 @@ your renderer component receives `error`, `disabled`, and `readOnly` inputs, and
339
358
 
340
359
  For submit-time whole-form validation, call `validateFields(fields, data)`.
341
360
 
361
+ ### Validation messages
362
+
363
+ Set the built-in validators' messages once per form instead of on every field:
364
+
365
+ ```ts
366
+ const store = createDynamicFormStore({
367
+ fields,
368
+ messages: { required: 'Bắt buộc', minLength: 'Tối thiểu {min} ký tự' },
369
+ });
370
+ ```
371
+
372
+ A message passed straight to a validator still wins, and any key omitted falls
373
+ back to the English default. `setDefaultMessages(catalog)` sets a process-wide
374
+ one for code calling `validateFields` directly. Full key list in the
375
+ [core README](../core/README.md#validation-messages). **No locale bundles
376
+ ship** — the mechanism is here, the translations are yours.
377
+
378
+ Forward `ariaInvalid`, `ariaRequired` and `ariaDescribedBy` from your renderer
379
+ too, and put `makeErrorId(id)` on whatever element shows the message.
380
+ `focusFirstInvalidField` selects `[aria-invalid="true"]`, so a renderer that
381
+ drops those props makes that helper silently do nothing. See
382
+ [the recipes](../../docs/ui-kit-recipes.md#forward-the-aria-props).
383
+
384
+ ### Async options
385
+
386
+ `options` may return a promise. The renderer receives `optionsStatus`
387
+ (`'idle' | 'loading' | 'ready' | 'error'`), `optionsError` and
388
+ `onOptionsQuery`:
389
+
390
+ ```ts
391
+ {
392
+ name: 'assignee',
393
+ type: 'userPicker',
394
+ options: async (data, _rootData, ctx) =>
395
+ fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
396
+ .then((r) => r.json()),
397
+ optionsDeps: (data) => [data.team], // reload when this changes; default []
398
+ debounceMs: 300, // collapses rapid reloads into one fetch
399
+ }
400
+ ```
401
+
402
+ Superseded requests are aborted and out-of-order responses discarded, so the
403
+ list always reflects the newest request. Static and synchronous options are
404
+ untouched and never enter a loading state. See the
405
+ [core README](../core/README.md#async-options).
406
+
342
407
  ## Repeatable field groups
343
408
 
344
409
  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.
@@ -2,8 +2,8 @@ import * as i0 from '@angular/core';
2
2
  import { EventEmitter, Output, Input, Component, InjectionToken, inject, ViewContainerRef, ViewChild, ChangeDetectionStrategy, HostListener, signal, computed, NgModule } from '@angular/core';
3
3
  import * as i1 from '@angular/common';
4
4
  import { CommonModule, NgIf, NgClass, NgFor, JsonPipe } from '@angular/common';
5
- import { fieldRegistry, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
6
- export { FieldRegistry, collectFieldPaths, fieldRegistry, indexGroupPathMap, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
5
+ import { fieldRegistry, makeErrorId, isAsyncOptions, createOptionsLoader, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, createMessageResolver, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
6
+ export { FIELD_RENDERER_PROP_KEYS, FieldRegistry, buildFieldRendererProps, collectFieldPaths, fieldRegistry, indexGroupPathMap, makeErrorId, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
7
7
 
8
8
  class BaseInputComponent {
9
9
  cdr;
@@ -23,6 +23,10 @@ class BaseInputComponent {
23
23
  dirty;
24
24
  error;
25
25
  options;
26
+ optionsStatus;
27
+ optionsError;
28
+ /** Renderer-driven refetch for a search-remote field. */
29
+ onOptionsQuery;
26
30
  className;
27
31
  description;
28
32
  id;
@@ -48,7 +52,7 @@ class BaseInputComponent {
48
52
  return changes[prop] !== undefined;
49
53
  }
50
54
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BaseInputComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
51
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: BaseInputComponent, isStandalone: true, selector: "ng-component", inputs: { value: "value", label: "label", placeholder: "placeholder", required: "required", disabled: "disabled", readOnly: "readOnly", touched: "touched", dirty: "dirty", error: "error", options: "options", className: "className", description: "description", id: "id", ariaInvalid: "ariaInvalid", ariaDescribedBy: "ariaDescribedBy", ariaRequired: "ariaRequired", min: "min", max: "max", step: "step", accept: "accept", multiple: "multiple" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: '', isInline: true });
55
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: BaseInputComponent, isStandalone: true, selector: "ng-component", inputs: { value: "value", label: "label", placeholder: "placeholder", required: "required", disabled: "disabled", readOnly: "readOnly", touched: "touched", dirty: "dirty", error: "error", options: "options", optionsStatus: "optionsStatus", optionsError: "optionsError", onOptionsQuery: "onOptionsQuery", className: "className", description: "description", id: "id", ariaInvalid: "ariaInvalid", ariaDescribedBy: "ariaDescribedBy", ariaRequired: "ariaRequired", min: "min", max: "max", step: "step", accept: "accept", multiple: "multiple" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: '', isInline: true });
52
56
  }
53
57
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BaseInputComponent, decorators: [{
54
58
  type: Component,
@@ -75,6 +79,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
75
79
  type: Input
76
80
  }], options: [{
77
81
  type: Input
82
+ }], optionsStatus: [{
83
+ type: Input
84
+ }], optionsError: [{
85
+ type: Input
86
+ }], onOptionsQuery: [{
87
+ type: Input
78
88
  }], className: [{
79
89
  type: Input
80
90
  }], description: [{
@@ -128,6 +138,8 @@ const KNOWN_PROPS = [
128
138
  'dirty',
129
139
  'error',
130
140
  'options',
141
+ 'optionsStatus',
142
+ 'optionsError',
131
143
  'className',
132
144
  'description',
133
145
  'id',
@@ -186,6 +198,9 @@ class DynamicInput extends BaseInputComponent {
186
198
  this.inputInstance[prop] = this[prop];
187
199
  }
188
200
  }
201
+ if (changes['onOptionsQuery']) {
202
+ this.applyCallbackProps(this.inputInstance);
203
+ }
189
204
  if (changes['extraProps']) {
190
205
  this.applyExtraProps(this.inputInstance);
191
206
  }
@@ -204,12 +219,33 @@ class DynamicInput extends BaseInputComponent {
204
219
  this.compRef = undefined;
205
220
  this.inputInstance = undefined;
206
221
  }
222
+ /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
223
+ errorNodeId() {
224
+ return makeErrorId(this.id ?? '');
225
+ }
226
+ /** `error` may arrive as a bare string, so index 0 would be a character. */
227
+ firstError() {
228
+ return Array.isArray(this.error) ? this.error[0] : this.error;
229
+ }
230
+ /**
231
+ * Whether the adapter should render the validation message itself.
232
+ *
233
+ * Asks the registry directly rather than reading a flag set by `render()`:
234
+ * `render()` runs in `ngAfterViewInit`, by which point this template's
235
+ * bindings have already been checked for the pass, and under `OnPush`
236
+ * nothing would mark them dirty again. A registered renderer owns its own
237
+ * error presentation, so only the built-in fallback gets a message here.
238
+ */
239
+ showDefaultError() {
240
+ return Boolean(!this.registry.get(this.type) && this.firstError() && this.id);
241
+ }
207
242
  render() {
208
243
  const Renderer = this.registry.get(this.type);
209
244
  this.cleanup();
210
245
  this.host.clear();
211
246
  if (!Renderer) {
212
247
  if (this.renderDefaultFallbackHTML5(this.type)) {
248
+ this.applyFallbackAria();
213
249
  return;
214
250
  }
215
251
  this.renderError(`Unknown field type: ${this.type}`);
@@ -273,6 +309,7 @@ class DynamicInput extends BaseInputComponent {
273
309
  ...this.extraProps,
274
310
  value: this.value,
275
311
  onValueChange: (v) => this.emitValue(v),
312
+ onOptionsQuery: this.onOptionsQuery,
276
313
  label: this.label ?? '',
277
314
  placeholder: this.placeholder ?? '',
278
315
  required: this.required ?? false,
@@ -320,8 +357,22 @@ class DynamicInput extends BaseInputComponent {
320
357
  instanceObj[prop] = this[prop];
321
358
  }
322
359
  }
360
+ this.applyCallbackProps(instance);
323
361
  this.applyExtraProps(instance);
324
362
  }
363
+ /**
364
+ * Props that are callbacks rather than resolved values, so they are not in
365
+ * `KNOWN_PROPS` / `FIELD_RENDERER_PROP_KEYS` and the loop above never sees
366
+ * them. Without this a renderer declaring `onOptionsQuery` always got
367
+ * `undefined` and could never trigger a search-remote refetch.
368
+ */
369
+ applyCallbackProps(instance) {
370
+ if (!instance) {
371
+ return;
372
+ }
373
+ instance['onOptionsQuery'] =
374
+ this.onOptionsQuery;
375
+ }
325
376
  applyExtraProps(instance) {
326
377
  if (!instance || !this.extraProps) {
327
378
  return;
@@ -348,6 +399,35 @@ class DynamicInput extends BaseInputComponent {
348
399
  this.valueChange.emit(value);
349
400
  this.onChange.emit(value);
350
401
  }
402
+ /**
403
+ * Puts the aria flags on whatever control the HTML5 fallback just built.
404
+ *
405
+ * Applied here, once, rather than in each of the fallback's branches: they
406
+ * hand-build an input, textarea, checkbox or select, and every one of them
407
+ * would otherwise need the same four lines. Without this the fallback emits
408
+ * the `{id}-error` node with nothing pointing at it, and
409
+ * `focusFirstInvalidField` - which selects `[aria-invalid="true"]` - still
410
+ * finds nothing on this adapter.
411
+ *
412
+ * `render()` re-runs on every ngOnChanges while the fallback is in use (there
413
+ * is no component instance to sync props into), so this stays current as the
414
+ * field's error comes and goes.
415
+ */
416
+ applyFallbackAria() {
417
+ const control = this.host.element.nativeElement.querySelector('input, select, textarea');
418
+ if (!control) {
419
+ return;
420
+ }
421
+ if (this.ariaInvalid !== undefined) {
422
+ control.setAttribute('aria-invalid', String(this.ariaInvalid));
423
+ }
424
+ if (this.ariaRequired !== undefined) {
425
+ control.setAttribute('aria-required', String(this.ariaRequired));
426
+ }
427
+ if (this.ariaDescribedBy) {
428
+ control.setAttribute('aria-describedby', this.ariaDescribedBy);
429
+ }
430
+ }
351
431
  renderDefaultFallbackHTML5(type) {
352
432
  const nativeEl = this.host.element.nativeElement;
353
433
  if (type === 'text' ||
@@ -479,7 +559,15 @@ class DynamicInput extends BaseInputComponent {
479
559
  this.host.element.nativeElement.appendChild(el);
480
560
  }
481
561
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicInput, deps: null, target: i0.ɵɵFactoryTarget.Component });
482
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicInput, isStandalone: true, selector: "dfk-dynamic-input", inputs: { type: "type", extraProps: "extraProps" }, outputs: { valueChange: "valueChange", onChange: "onChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, read: ViewContainerRef }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `<div #host style="display: contents;"></div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
562
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicInput, isStandalone: true, selector: "dfk-dynamic-input", inputs: { type: "type", extraProps: "extraProps" }, outputs: { valueChange: "valueChange", onChange: "onChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, read: ViewContainerRef }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `<div #host style="display: contents;"></div>
563
+ <div
564
+ *ngIf="showDefaultError()"
565
+ [id]="errorNodeId()"
566
+ class="dfk-field-error"
567
+ role="alert"
568
+ >
569
+ {{ firstError() }}
570
+ </div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
483
571
  }
484
572
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicInput, decorators: [{
485
573
  type: Component,
@@ -488,7 +576,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
488
576
  standalone: true,
489
577
  imports: [CommonModule],
490
578
  changeDetection: ChangeDetectionStrategy.OnPush,
491
- template: `<div #host style="display: contents;"></div>`,
579
+ // *ngIf rather than @if: the peer range starts at Angular 16, and the
580
+ // built-in control flow block syntax is 17+.
581
+ template: `<div #host style="display: contents;"></div>
582
+ <div
583
+ *ngIf="showDefaultError()"
584
+ [id]="errorNodeId()"
585
+ class="dfk-field-error"
586
+ role="alert"
587
+ >
588
+ {{ firstError() }}
589
+ </div>`,
492
590
  }]
493
591
  }], propDecorators: { type: [{
494
592
  type: Input
@@ -545,10 +643,45 @@ class FieldInput {
545
643
  constructor(cdr) {
546
644
  this.cdr = cdr;
547
645
  }
646
+ // Async options only; a static or synchronous list allocates nothing here.
647
+ loader;
648
+ optionsState;
649
+ /** Bound into the template so a renderer can drive a search-remote refetch. */
650
+ onOptionsQuery = (query) => {
651
+ this.loader?.setQuery(query);
652
+ };
548
653
  ngOnChanges(_changes) {
654
+ this.syncOptionsLoader();
549
655
  this.rendererProps = this.buildProps();
550
656
  this.cdr.markForCheck();
551
657
  }
658
+ ngOnDestroy() {
659
+ this.loader?.dispose();
660
+ }
661
+ syncOptionsLoader() {
662
+ const field = this.fieldDescription;
663
+ if (!field || !isAsyncOptions(field)) {
664
+ // A field can be swapped for one with synchronous options. Without this,
665
+ // the stale optionsState would keep winning in buildFieldRendererProps
666
+ // (`optionsState ? optionsState.options : resolveOptions(...)`) and the
667
+ // old async list would be served forever, with the loader never disposed.
668
+ this.loader?.dispose();
669
+ this.loader = undefined;
670
+ this.optionsState = undefined;
671
+ return;
672
+ }
673
+ if (!this.loader) {
674
+ this.optionsState = { status: 'idle' };
675
+ this.loader = createOptionsLoader(field, (state) => {
676
+ this.optionsState = state;
677
+ this.rendererProps = this.buildProps();
678
+ // OnPush: an async arrival happens outside any event this view is
679
+ // checked for, so without this the options would load and never appear.
680
+ this.cdr.markForCheck();
681
+ });
682
+ }
683
+ this.loader.update(this.data ?? {}, this.rootData);
684
+ }
552
685
  buildProps() {
553
686
  const field = this.fieldDescription;
554
687
  if (!field) {
@@ -562,6 +695,7 @@ class FieldInput {
562
695
  id: makeFieldId(field, this.idPrefix),
563
696
  touched: this.touched,
564
697
  dirty: this.dirty,
698
+ optionsState: this.optionsState,
565
699
  });
566
700
  // Explicitly bound inputs override what the field description resolves to,
567
701
  // so a host can still drive options/disabled/error itself.
@@ -588,6 +722,8 @@ class FieldInput {
588
722
  [required]="p.required"
589
723
  [description]="$any(p.description)"
590
724
  [options]="$any(p.options)"
725
+ [optionsStatus]="p.optionsStatus"
726
+ [optionsError]="p.optionsError"
591
727
  [className]="p.className"
592
728
  [disabled]="p.disabled"
593
729
  [readOnly]="p.readOnly"
@@ -608,6 +744,7 @@ class FieldInput {
608
744
  "
609
745
  (focusout)="onBlurField.emit(fieldDescription!.name)"
610
746
  [extraProps]="p.extraProps"
747
+ [onOptionsQuery]="onOptionsQuery"
611
748
  ></dfk-dynamic-input>
612
749
  `, isInline: true, dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: DynamicInput, selector: "dfk-dynamic-input", inputs: ["type", "extraProps"], outputs: ["valueChange", "onChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
613
750
  }
@@ -628,6 +765,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
628
765
  [required]="p.required"
629
766
  [description]="$any(p.description)"
630
767
  [options]="$any(p.options)"
768
+ [optionsStatus]="p.optionsStatus"
769
+ [optionsError]="p.optionsError"
631
770
  [className]="p.className"
632
771
  [disabled]="p.disabled"
633
772
  [readOnly]="p.readOnly"
@@ -648,6 +787,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
648
787
  "
649
788
  (focusout)="onBlurField.emit(fieldDescription!.name)"
650
789
  [extraProps]="p.extraProps"
790
+ [onOptionsQuery]="onOptionsQuery"
651
791
  ></dfk-dynamic-input>
652
792
  `,
653
793
  }]
@@ -700,6 +840,13 @@ class MultiFieldInput {
700
840
  cdr;
701
841
  fieldDescriptions = [];
702
842
  properties;
843
+ /**
844
+ * The values per-field `dirty` is measured against. Defaults to the first
845
+ * non-`undefined` `properties` this component sees. This adapter has no
846
+ * `form` shorthand, so pass `store.baselineValues()` here to keep `dirty`
847
+ * correct across `store.reset(newValues)`.
848
+ */
849
+ initialProperties;
703
850
  onChange = new EventEmitter();
704
851
  validityChange = new EventEmitter();
705
852
  /**
@@ -734,7 +881,7 @@ class MultiFieldInput {
734
881
  // component is client-rendered by the time ids matter, so a module counter
735
882
  // is enough.
736
883
  instanceId = nextInstanceId();
737
- initialProperties = {};
884
+ firstSeenProperties = {};
738
885
  indexedErrorsSource;
739
886
  indexedErrors = new Map();
740
887
  indexedTouchedSource;
@@ -760,7 +907,8 @@ class MultiFieldInput {
760
907
  }
761
908
  /** Whether this field's value differs from the one the form opened with. */
762
909
  isFieldDirty(fieldName) {
763
- return this.data[fieldName] !== this.initialProperties[fieldName];
910
+ const baseline = this.initialProperties ?? this.firstSeenProperties;
911
+ return !Object.is(this.data[fieldName], baseline[fieldName]);
764
912
  }
765
913
  fieldErrors(fieldName) {
766
914
  return this.errors?.[fieldName];
@@ -903,7 +1051,7 @@ class MultiFieldInput {
903
1051
  // Baseline for the `dirty` flag: the values the form opened with, not
904
1052
  // whatever `properties` happens to hold after later edits.
905
1053
  if (!this.initialised) {
906
- this.initialProperties = { ...this.properties };
1054
+ this.firstSeenProperties = { ...this.properties };
907
1055
  this.initialised = true;
908
1056
  }
909
1057
  }
@@ -962,7 +1110,7 @@ class MultiFieldInput {
962
1110
  this.cdr.markForCheck();
963
1111
  }
964
1112
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiFieldInput, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
965
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: MultiFieldInput, isStandalone: true, selector: "dfk-multi-field-input", inputs: { fieldDescriptions: "fieldDescriptions", properties: "properties", touched: "touched", errors: "errors", idPrefix: "idPrefix", layout: "layout", rootData: "rootData" }, outputs: { onChange: "onChange", validityChange: "validityChange", onBlurField: "onBlurField", touchedChange: "touchedChange" }, host: { listeners: { "window:resize": "onWindowResize()" } }, usesOnChanges: true, ngImport: i0, template: `
1113
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: MultiFieldInput, isStandalone: true, selector: "dfk-multi-field-input", inputs: { fieldDescriptions: "fieldDescriptions", properties: "properties", initialProperties: "initialProperties", touched: "touched", errors: "errors", idPrefix: "idPrefix", layout: "layout", rootData: "rootData" }, outputs: { onChange: "onChange", validityChange: "validityChange", onBlurField: "onBlurField", touchedChange: "touchedChange" }, host: { listeners: { "window:resize": "onWindowResize()" } }, usesOnChanges: true, ngImport: i0, template: `
966
1114
  <div
967
1115
  class="p-4 border rounded-lg bg-gray-50"
968
1116
  [ngClass]="{
@@ -1039,7 +1187,7 @@ class MultiFieldInput {
1039
1187
  </div>
1040
1188
  </ng-container>
1041
1189
  </div>
1042
- `, isInline: true, dependencies: [{ kind: "component", type: MultiFieldInput, selector: "dfk-multi-field-input", inputs: ["fieldDescriptions", "properties", "touched", "errors", "idPrefix", "layout", "rootData"], outputs: ["onChange", "validityChange", "onBlurField", "touchedChange"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: FieldInput, selector: "dfk-field-input", inputs: ["fieldDescription", "data", "rootData", "value", "options", "disabled", "readOnly", "error", "validationControlled", "touched", "dirty", "idPrefix"], outputs: ["onValueChangeField", "onBlurField"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1190
+ `, isInline: true, dependencies: [{ kind: "component", type: MultiFieldInput, selector: "dfk-multi-field-input", inputs: ["fieldDescriptions", "properties", "initialProperties", "touched", "errors", "idPrefix", "layout", "rootData"], outputs: ["onChange", "validityChange", "onBlurField", "touchedChange"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: FieldInput, selector: "dfk-field-input", inputs: ["fieldDescription", "data", "rootData", "value", "options", "disabled", "readOnly", "error", "validationControlled", "touched", "dirty", "idPrefix"], outputs: ["onValueChangeField", "onBlurField"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1043
1191
  }
1044
1192
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiFieldInput, decorators: [{
1045
1193
  type: Component,
@@ -1137,6 +1285,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1137
1285
  type: Input
1138
1286
  }], properties: [{
1139
1287
  type: Input
1288
+ }], initialProperties: [{
1289
+ type: Input
1140
1290
  }], onChange: [{
1141
1291
  type: Output
1142
1292
  }], validityChange: [{
@@ -1471,13 +1621,20 @@ function createDynamicFormStore(options) {
1471
1621
  const initialValues = options.initialValues || {};
1472
1622
  const validateOnBlur = options.validateOnBlur ?? true;
1473
1623
  const validateOnChange = options.validateOnChange ?? false;
1624
+ const validationContext = {
1625
+ t: createMessageResolver(options.messages),
1626
+ };
1474
1627
  const data = signal(applyComputedValues(fields, initialValues), ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
1628
+ // The baseline `dirty` is measured against: the initialValues option until
1629
+ // reset(newValues) replaces it. Distinct from that option, which never
1630
+ // changes. See the React adapter for the full rationale.
1631
+ const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
1475
1632
  const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
1476
1633
  const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
1477
1634
  const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
1478
1635
  const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
1479
1636
  const isSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitted" }] : /* istanbul ignore next */ []));
1480
- const validationResult = signal(validateFields(fields, data()), ...(ngDevMode ? [{ debugName: "validationResult" }] : /* istanbul ignore next */ []));
1637
+ const validationResult = signal(validateFields(fields, data(), undefined, validationContext), ...(ngDevMode ? [{ debugName: "validationResult" }] : /* istanbul ignore next */ []));
1481
1638
  const isValidating = signal(false, ...(ngDevMode ? [{ debugName: "isValidating" }] : /* istanbul ignore next */ []));
1482
1639
  let validationRun = 0;
1483
1640
  let validationController;
@@ -1493,7 +1650,7 @@ function createDynamicFormStore(options) {
1493
1650
  return res.valid;
1494
1651
  }
1495
1652
  function validate() {
1496
- const res = validateFields(fields, data());
1653
+ const res = validateFields(fields, data(), undefined, validationContext);
1497
1654
  errors.set(res.errors);
1498
1655
  return commitSyncResult(res);
1499
1656
  }
@@ -1506,6 +1663,7 @@ function createDynamicFormStore(options) {
1506
1663
  isValidating.set(true);
1507
1664
  try {
1508
1665
  const res = await validateFieldsAsync(fields, snapshot, snapshot, {
1666
+ ...validationContext,
1509
1667
  signal: controller.signal,
1510
1668
  });
1511
1669
  if (run !== validationRun || data() !== snapshot) {
@@ -1528,7 +1686,7 @@ function createDynamicFormStore(options) {
1528
1686
  validationController?.abort();
1529
1687
  validationRun += 1;
1530
1688
  isValidating.set(false);
1531
- const res = validateFields(fields, next);
1689
+ const res = validateFields(fields, next, undefined, validationContext);
1532
1690
  commitSyncResult(res);
1533
1691
  if (validateOnChange) {
1534
1692
  errors.set(res.errors);
@@ -1549,13 +1707,24 @@ function createDynamicFormStore(options) {
1549
1707
  touched.set(Object.fromEntries(collectFieldPaths(fields, data()).map((path) => [path, true])));
1550
1708
  }
1551
1709
  /** Clears the touched map without touching data, errors or dirty state. */
1710
+ function getDirtyValues() {
1711
+ const baseline = baselineValues();
1712
+ const current = data();
1713
+ const dirty = {};
1714
+ for (const key of Object.keys(current)) {
1715
+ if (!Object.is(current[key], baseline[key])) {
1716
+ dirty[key] = current[key];
1717
+ }
1718
+ }
1719
+ return dirty;
1720
+ }
1552
1721
  function resetTouched() {
1553
1722
  touched.set({});
1554
1723
  }
1555
1724
  function handleBlur(fieldName) {
1556
1725
  setFieldTouched(fieldName, true);
1557
1726
  if (validateOnBlur) {
1558
- const res = validateFields(fields, data());
1727
+ const res = validateFields(fields, data(), undefined, validationContext);
1559
1728
  errors.set(res.errors);
1560
1729
  commitSyncResult(res);
1561
1730
  }
@@ -1564,6 +1733,7 @@ function createDynamicFormStore(options) {
1564
1733
  const seed = newValues ?? initialValues;
1565
1734
  const next = applyComputedValues(fields, seed);
1566
1735
  data.set(next);
1736
+ baselineValues.set({ ...next });
1567
1737
  errors.set({});
1568
1738
  isDirty.set(false);
1569
1739
  touched.set({});
@@ -1572,7 +1742,7 @@ function createDynamicFormStore(options) {
1572
1742
  validationController?.abort();
1573
1743
  validationRun += 1;
1574
1744
  isValidating.set(false);
1575
- commitSyncResult(validateFields(fields, next));
1745
+ commitSyncResult(validateFields(fields, next, undefined, validationContext));
1576
1746
  }
1577
1747
  /**
1578
1748
  * Returns a submit handler, mirroring the React and Vue `useDynamicForm`
@@ -1602,6 +1772,7 @@ function createDynamicFormStore(options) {
1602
1772
  const snapshot = data();
1603
1773
  isValidating.set(true);
1604
1774
  const res = await validateFieldsAsync(fields, snapshot, snapshot, {
1775
+ ...validationContext,
1605
1776
  signal: controller.signal,
1606
1777
  });
1607
1778
  if (thisSubmit !== submitRun) {
@@ -1616,7 +1787,7 @@ function createDynamicFormStore(options) {
1616
1787
  validationResult.set(res);
1617
1788
  }
1618
1789
  else {
1619
- const live = validateFields(fields, data());
1790
+ const live = validateFields(fields, data(), undefined, validationContext);
1620
1791
  errors.set(live.errors);
1621
1792
  validationResult.set(live);
1622
1793
  }
@@ -1644,6 +1815,8 @@ function createDynamicFormStore(options) {
1644
1815
  isValidationComplete,
1645
1816
  validationStatus,
1646
1817
  isDirty,
1818
+ baselineValues,
1819
+ getDirtyValues,
1647
1820
  touched,
1648
1821
  isSubmitting,
1649
1822
  isSubmitted,
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { OnChanges, ChangeDetectorRef, EventEmitter, SimpleChanges, AfterViewInit, OnDestroy, ViewContainerRef, OnInit, TemplateRef, Type, InjectionToken } from '@angular/core';
3
- import { FieldTypeKey, Properties, FieldDescription, ResolvedFieldRendererProps, BaseLayout, ResponsiveLayout, ValidationResult, LayoutConfig, FieldRegistry } from '@dynamic-field-kit/core';
4
- export { ColumnLayoutConfig, FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, GridLayoutConfig, LayoutConfig, RowLayoutConfig, ValidationContext, ValidationResult, collectFieldPaths, fieldRegistry, indexGroupPathMap, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
3
+ import { OptionsStatus, FieldTypeKey, Properties, FieldDescription, ResolvedFieldRendererProps, BaseLayout, ResponsiveLayout, ValidationResult, LayoutConfig, MessageCatalog, FieldRegistry } from '@dynamic-field-kit/core';
4
+ export { ColumnLayoutConfig, FIELD_RENDERER_PROP_KEYS, FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, GridLayoutConfig, LayoutConfig, RowLayoutConfig, ValidationContext, ValidationResult, buildFieldRendererProps, collectFieldPaths, fieldRegistry, indexGroupPathMap, makeErrorId, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
5
5
  import * as i1 from '@angular/common';
6
6
 
7
7
  interface FieldInputProps {
@@ -15,6 +15,9 @@ interface FieldInputProps {
15
15
  dirty?: boolean;
16
16
  error?: string | string[];
17
17
  options?: unknown[];
18
+ optionsStatus?: OptionsStatus;
19
+ optionsError?: unknown;
20
+ onOptionsQuery?: (query: string) => void;
18
21
  className?: string;
19
22
  description?: string;
20
23
  id?: string;
@@ -45,6 +48,10 @@ declare abstract class BaseInputComponent implements OnChanges {
45
48
  dirty?: boolean;
46
49
  error?: string | string[];
47
50
  options?: unknown[];
51
+ optionsStatus?: OptionsStatus;
52
+ optionsError?: unknown;
53
+ /** Renderer-driven refetch for a search-remote field. */
54
+ onOptionsQuery?: (query: string) => void;
48
55
  className?: string;
49
56
  description?: string;
50
57
  id?: string;
@@ -62,7 +69,7 @@ declare abstract class BaseInputComponent implements OnChanges {
62
69
  protected detectChanges(): void;
63
70
  protected hasChanges(changes: SimpleChanges, prop: string): boolean;
64
71
  static ɵfac: i0.ɵɵFactoryDeclaration<BaseInputComponent, never>;
65
- static ɵcmp: i0.ɵɵComponentDeclaration<BaseInputComponent, "ng-component", never, { "value": { "alias": "value"; "required": false; }; "label": { "alias": "label"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "readOnly": { "alias": "readOnly"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "dirty": { "alias": "dirty"; "required": false; }; "error": { "alias": "error"; "required": false; }; "options": { "alias": "options"; "required": false; }; "className": { "alias": "className"; "required": false; }; "description": { "alias": "description"; "required": false; }; "id": { "alias": "id"; "required": false; }; "ariaInvalid": { "alias": "ariaInvalid"; "required": false; }; "ariaDescribedBy": { "alias": "ariaDescribedBy"; "required": false; }; "ariaRequired": { "alias": "ariaRequired"; "required": false; }; "min": { "alias": "min"; "required": false; }; "max": { "alias": "max"; "required": false; }; "step": { "alias": "step"; "required": false; }; "accept": { "alias": "accept"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; }, { "valueChange": "valueChange"; }, never, never, true, never>;
72
+ static ɵcmp: i0.ɵɵComponentDeclaration<BaseInputComponent, "ng-component", never, { "value": { "alias": "value"; "required": false; }; "label": { "alias": "label"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "readOnly": { "alias": "readOnly"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "dirty": { "alias": "dirty"; "required": false; }; "error": { "alias": "error"; "required": false; }; "options": { "alias": "options"; "required": false; }; "optionsStatus": { "alias": "optionsStatus"; "required": false; }; "optionsError": { "alias": "optionsError"; "required": false; }; "onOptionsQuery": { "alias": "onOptionsQuery"; "required": false; }; "className": { "alias": "className"; "required": false; }; "description": { "alias": "description"; "required": false; }; "id": { "alias": "id"; "required": false; }; "ariaInvalid": { "alias": "ariaInvalid"; "required": false; }; "ariaDescribedBy": { "alias": "ariaDescribedBy"; "required": false; }; "ariaRequired": { "alias": "ariaRequired"; "required": false; }; "min": { "alias": "min"; "required": false; }; "max": { "alias": "max"; "required": false; }; "step": { "alias": "step"; "required": false; }; "accept": { "alias": "accept"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; }, { "valueChange": "valueChange"; }, never, never, true, never>;
66
73
  }
67
74
 
68
75
  declare class DynamicInput extends BaseInputComponent implements OnChanges, AfterViewInit, OnDestroy {
@@ -83,22 +90,58 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
83
90
  private cleanup;
84
91
  private cleanupSubscriptions;
85
92
  private cleanupRenderedComponent;
93
+ /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
94
+ errorNodeId(): string;
95
+ /** `error` may arrive as a bare string, so index 0 would be a character. */
96
+ firstError(): string | undefined;
97
+ /**
98
+ * Whether the adapter should render the validation message itself.
99
+ *
100
+ * Asks the registry directly rather than reading a flag set by `render()`:
101
+ * `render()` runs in `ngAfterViewInit`, by which point this template's
102
+ * bindings have already been checked for the pass, and under `OnPush`
103
+ * nothing would mark them dirty again. A registered renderer owns its own
104
+ * error presentation, so only the built-in fallback gets a message here.
105
+ */
106
+ showDefaultError(): boolean;
86
107
  private render;
87
108
  private isComponentType;
88
109
  private renderComponent;
89
110
  private renderFallback;
90
111
  private getFallbackProps;
91
112
  private applyProps;
113
+ /**
114
+ * Props that are callbacks rather than resolved values, so they are not in
115
+ * `KNOWN_PROPS` / `FIELD_RENDERER_PROP_KEYS` and the loop above never sees
116
+ * them. Without this a renderer declaring `onOptionsQuery` always got
117
+ * `undefined` and could never trigger a search-remote refetch.
118
+ */
119
+ private applyCallbackProps;
92
120
  private applyExtraProps;
93
121
  private bindOutputs;
94
122
  private emitValue;
123
+ /**
124
+ * Puts the aria flags on whatever control the HTML5 fallback just built.
125
+ *
126
+ * Applied here, once, rather than in each of the fallback's branches: they
127
+ * hand-build an input, textarea, checkbox or select, and every one of them
128
+ * would otherwise need the same four lines. Without this the fallback emits
129
+ * the `{id}-error` node with nothing pointing at it, and
130
+ * `focusFirstInvalidField` - which selects `[aria-invalid="true"]` - still
131
+ * finds nothing on this adapter.
132
+ *
133
+ * `render()` re-runs on every ngOnChanges while the fallback is in use (there
134
+ * is no component instance to sync props into), so this stays current as the
135
+ * field's error comes and goes.
136
+ */
137
+ private applyFallbackAria;
95
138
  private renderDefaultFallbackHTML5;
96
139
  private renderError;
97
140
  static ɵfac: i0.ɵɵFactoryDeclaration<DynamicInput, never>;
98
141
  static ɵcmp: i0.ɵɵComponentDeclaration<DynamicInput, "dfk-dynamic-input", never, { "type": { "alias": "type"; "required": false; }; "extraProps": { "alias": "extraProps"; "required": false; }; }, { "valueChange": "valueChange"; "onChange": "onChange"; }, never, never, true, never>;
99
142
  }
100
143
 
101
- declare class FieldInput implements OnChanges {
144
+ declare class FieldInput implements OnChanges, OnDestroy {
102
145
  private cdr;
103
146
  fieldDescription?: FieldDescription;
104
147
  /**
@@ -141,7 +184,13 @@ declare class FieldInput implements OnChanges {
141
184
  */
142
185
  rendererProps: ResolvedFieldRendererProps | null;
143
186
  constructor(cdr: ChangeDetectorRef);
187
+ private loader?;
188
+ private optionsState?;
189
+ /** Bound into the template so a renderer can drive a search-remote refetch. */
190
+ onOptionsQuery: (query: string) => void;
144
191
  ngOnChanges(_changes: SimpleChanges): void;
192
+ ngOnDestroy(): void;
193
+ private syncOptionsLoader;
145
194
  private buildProps;
146
195
  static ɵfac: i0.ɵɵFactoryDeclaration<FieldInput, never>;
147
196
  static ɵcmp: i0.ɵɵComponentDeclaration<FieldInput, "dfk-field-input", never, { "fieldDescription": { "alias": "fieldDescription"; "required": false; }; "data": { "alias": "data"; "required": false; }; "rootData": { "alias": "rootData"; "required": false; }; "value": { "alias": "value"; "required": false; }; "options": { "alias": "options"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "readOnly": { "alias": "readOnly"; "required": false; }; "error": { "alias": "error"; "required": false; }; "validationControlled": { "alias": "validationControlled"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "dirty": { "alias": "dirty"; "required": false; }; "idPrefix": { "alias": "idPrefix"; "required": false; }; }, { "onValueChangeField": "onValueChangeField"; "onBlurField": "onBlurField"; }, never, never, true, never>;
@@ -154,6 +203,13 @@ declare class MultiFieldInput implements OnInit, OnChanges {
154
203
  private cdr;
155
204
  fieldDescriptions: FieldDescription[];
156
205
  properties?: Properties;
206
+ /**
207
+ * The values per-field `dirty` is measured against. Defaults to the first
208
+ * non-`undefined` `properties` this component sees. This adapter has no
209
+ * `form` shorthand, so pass `store.baselineValues()` here to keep `dirty`
210
+ * correct across `store.reset(newValues)`.
211
+ */
212
+ initialProperties?: Properties;
157
213
  onChange: EventEmitter<Properties>;
158
214
  validityChange: EventEmitter<ValidationResult>;
159
215
  /**
@@ -185,7 +241,7 @@ declare class MultiFieldInput implements OnInit, OnChanges {
185
241
  idPrefix?: string;
186
242
  private touchedFields;
187
243
  private readonly instanceId;
188
- private initialProperties;
244
+ private firstSeenProperties;
189
245
  private indexedErrorsSource?;
190
246
  private indexedErrors;
191
247
  private indexedTouchedSource?;
@@ -242,7 +298,7 @@ declare class MultiFieldInput implements OnInit, OnChanges {
242
298
  onGroupItemChange(field: FieldDescription, index: number, next: Properties): void;
243
299
  private commitData;
244
300
  static ɵfac: i0.ɵɵFactoryDeclaration<MultiFieldInput, never>;
245
- static ɵcmp: i0.ɵɵComponentDeclaration<MultiFieldInput, "dfk-multi-field-input", never, { "fieldDescriptions": { "alias": "fieldDescriptions"; "required": false; }; "properties": { "alias": "properties"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "errors": { "alias": "errors"; "required": false; }; "idPrefix": { "alias": "idPrefix"; "required": false; }; "layout": { "alias": "layout"; "required": false; }; "rootData": { "alias": "rootData"; "required": false; }; }, { "onChange": "onChange"; "validityChange": "validityChange"; "onBlurField": "onBlurField"; "touchedChange": "touchedChange"; }, never, never, true, never>;
301
+ static ɵcmp: i0.ɵɵComponentDeclaration<MultiFieldInput, "dfk-multi-field-input", never, { "fieldDescriptions": { "alias": "fieldDescriptions"; "required": false; }; "properties": { "alias": "properties"; "required": false; }; "initialProperties": { "alias": "initialProperties"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "errors": { "alias": "errors"; "required": false; }; "idPrefix": { "alias": "idPrefix"; "required": false; }; "layout": { "alias": "layout"; "required": false; }; "rootData": { "alias": "rootData"; "required": false; }; }, { "onChange": "onChange"; "validityChange": "validityChange"; "onBlurField": "onBlurField"; "touchedChange": "touchedChange"; }, never, never, true, never>;
246
302
  }
247
303
 
248
304
  declare class DynamicFormDevToolsComponent {
@@ -265,6 +321,13 @@ interface DynamicFormOptions {
265
321
  initialValues?: Properties;
266
322
  validateOnBlur?: boolean;
267
323
  validateOnChange?: boolean;
324
+ /**
325
+ * Messages for the built-in validators, set once for the whole form instead
326
+ * of per field. A message passed directly to a validator still wins, and any
327
+ * key omitted here falls back to the validator's English default. See core's
328
+ * `MessageCatalog`.
329
+ */
330
+ messages?: MessageCatalog;
268
331
  }
269
332
  declare function createDynamicFormStore(options: DynamicFormOptions): {
270
333
  data: i0.WritableSignal<Properties>;
@@ -274,6 +337,8 @@ declare function createDynamicFormStore(options: DynamicFormOptions): {
274
337
  isValidationComplete: i0.Signal<boolean>;
275
338
  validationStatus: i0.Signal<"invalid" | "valid" | "pending">;
276
339
  isDirty: i0.WritableSignal<boolean>;
340
+ baselineValues: i0.WritableSignal<Properties>;
341
+ getDirtyValues: () => Properties;
277
342
  touched: i0.WritableSignal<Record<string, boolean>>;
278
343
  isSubmitting: i0.WritableSignal<boolean>;
279
344
  isSubmitted: i0.WritableSignal<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamic-field-kit/angular",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Angular renderer for dynamic-field-kit",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -54,7 +54,7 @@
54
54
  "@angular/core": "^21.2.0",
55
55
  "@angular/forms": "^21.2.0",
56
56
  "@angular/platform-browser": "^21.2.0",
57
- "@dynamic-field-kit/core": "^1.6.0",
57
+ "@dynamic-field-kit/core": "^1.7.0",
58
58
  "@vitest/coverage-istanbul": "^4.1.11",
59
59
  "jsdom": "^29.1.1",
60
60
  "ng-packagr": "^21.2.0",