`, which is what
+`aria-describedby` points at. Before that they were handed `error` and dropped
+it, so the form showed nothing. A registered custom renderer is unaffected — the
+node is emitted only where a default was used, so you never get two copies. Hide
+it with `.dfk-field-error { display: none }` if you want the old silence.
+
## DevTools
```html
@@ -339,6 +358,52 @@ your renderer component receives `error`, `disabled`, and `readOnly` inputs, and
For submit-time whole-form validation, call `validateFields(fields, data)`.
+### Validation messages
+
+Set the built-in validators' messages once per form instead of on every field:
+
+```ts
+const store = createDynamicFormStore({
+ fields,
+ messages: { required: 'Bắt buộc', minLength: 'Tối thiểu {min} ký tự' },
+});
+```
+
+A message passed straight to a validator still wins, and any key omitted falls
+back to the English default. `setDefaultMessages(catalog)` sets a process-wide
+one for code calling `validateFields` directly. Full key list in the
+[core README](../core/README.md#validation-messages). **No locale bundles
+ship** — the mechanism is here, the translations are yours.
+
+Forward `ariaInvalid`, `ariaRequired` and `ariaDescribedBy` from your renderer
+too, and put `makeErrorId(id)` on whatever element shows the message.
+`focusFirstInvalidField` selects `[aria-invalid="true"]`, so a renderer that
+drops those props makes that helper silently do nothing. See
+[the recipes](../../docs/ui-kit-recipes.md#forward-the-aria-props).
+
+### Async options
+
+`options` may return a promise. The renderer receives `optionsStatus`
+(`'idle' | 'loading' | 'ready' | 'error'`), `optionsError` and
+`onOptionsQuery`:
+
+```ts
+{
+ name: 'assignee',
+ type: 'userPicker',
+ options: async (data, _rootData, ctx) =>
+ fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
+ .then((r) => r.json()),
+ optionsDeps: (data) => [data.team], // reload when this changes; default []
+ debounceMs: 300, // collapses rapid reloads into one fetch
+}
+```
+
+Superseded requests are aborted and out-of-order responses discarded, so the
+list always reflects the newest request. Static and synchronous options are
+untouched and never enter a loading state. See the
+[core README](../core/README.md#async-options).
+
## Repeatable field groups
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.
diff --git a/packages/angular/package.json b/packages/angular/package.json
index 67118f7..362d4bd 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -1,6 +1,6 @@
{
"name": "@dynamic-field-kit/angular",
- "version": "1.6.0",
+ "version": "1.7.0",
"description": "Angular renderer for dynamic-field-kit",
"license": "MIT",
"private": false,
@@ -54,7 +54,7 @@
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
- "@dynamic-field-kit/core": "^1.6.0",
+ "@dynamic-field-kit/core": "^1.7.0",
"@vitest/coverage-istanbul": "^4.1.11",
"jsdom": "^29.1.1",
"ng-packagr": "^21.2.0",
diff --git a/packages/angular/src/components/BaseInput.ts b/packages/angular/src/components/BaseInput.ts
index 6591803..b593330 100644
--- a/packages/angular/src/components/BaseInput.ts
+++ b/packages/angular/src/components/BaseInput.ts
@@ -8,6 +8,8 @@ import {
SimpleChanges,
} from '@angular/core';
+import type { OptionsStatus } from '@dynamic-field-kit/core';
+
// Mirrors the framework-agnostic FieldRendererProps (core's
// FIELD_RENDERER_PROP_KEYS). Domain-specific inputs (acceptFile, maxLength,
// ...) intentionally live on the individual renderer, not here - pass them per
@@ -23,6 +25,9 @@ export interface FieldInputProps {
dirty?: boolean;
error?: string | string[];
options?: unknown[];
+ optionsStatus?: OptionsStatus;
+ optionsError?: unknown;
+ onOptionsQuery?: (query: string) => void;
className?: string;
description?: string;
id?: string;
@@ -56,6 +61,10 @@ export abstract class BaseInputComponent implements OnChanges {
@Input() dirty?: boolean;
@Input() error?: string | string[];
@Input() options?: unknown[];
+ @Input() optionsStatus?: OptionsStatus;
+ @Input() optionsError?: unknown;
+ /** Renderer-driven refetch for a search-remote field. */
+ @Input() onOptionsQuery?: (query: string) => void;
@Input() className?: string;
@Input() description?: string;
@Input() id?: string;
diff --git a/packages/angular/src/components/DynamicInput.ts b/packages/angular/src/components/DynamicInput.ts
index 3627a85..5df743d 100644
--- a/packages/angular/src/components/DynamicInput.ts
+++ b/packages/angular/src/components/DynamicInput.ts
@@ -15,7 +15,7 @@ import {
ViewChild,
ViewContainerRef,
} from '@angular/core';
-import { FieldTypeKey, Properties } from '@dynamic-field-kit/core';
+import { FieldTypeKey, makeErrorId, Properties } from '@dynamic-field-kit/core';
import { Subscription } from 'rxjs';
import { FIELD_REGISTRY } from '../fieldRegistryToken';
import { BaseInputComponent } from './BaseInput';
@@ -37,6 +37,8 @@ const KNOWN_PROPS = [
'dirty',
'error',
'options',
+ 'optionsStatus',
+ 'optionsError',
'className',
'description',
'id',
@@ -55,7 +57,17 @@ const KNOWN_PROPS = [
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
`,
+ // *ngIf rather than @if: the peer range starts at Angular 16, and the
+ // built-in control flow block syntax is 17+.
+ template: `
+
+ {{ firstError() }}
+
`,
})
export class DynamicInput
extends BaseInputComponent
@@ -118,6 +130,9 @@ export class DynamicInput
)[prop];
}
}
+ if (changes['onOptionsQuery']) {
+ this.applyCallbackProps(this.inputInstance);
+ }
if (changes['extraProps']) {
this.applyExtraProps(this.inputInstance);
}
@@ -140,6 +155,31 @@ export class DynamicInput
this.inputInstance = undefined;
}
+ /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
+ errorNodeId(): string {
+ return makeErrorId(this.id ?? '');
+ }
+
+ /** `error` may arrive as a bare string, so index 0 would be a character. */
+ firstError(): string | undefined {
+ return Array.isArray(this.error) ? this.error[0] : this.error;
+ }
+
+ /**
+ * Whether the adapter should render the validation message itself.
+ *
+ * Asks the registry directly rather than reading a flag set by `render()`:
+ * `render()` runs in `ngAfterViewInit`, by which point this template's
+ * bindings have already been checked for the pass, and under `OnPush`
+ * nothing would mark them dirty again. A registered renderer owns its own
+ * error presentation, so only the built-in fallback gets a message here.
+ */
+ showDefaultError(): boolean {
+ return Boolean(
+ !this.registry.get(this.type) && this.firstError() && this.id,
+ );
+ }
+
private render(): void {
const Renderer = this.registry.get(this.type);
this.cleanup();
@@ -147,6 +187,7 @@ export class DynamicInput
if (!Renderer) {
if (this.renderDefaultFallbackHTML5(this.type)) {
+ this.applyFallbackAria();
return;
}
this.renderError(`Unknown field type: ${this.type}`);
@@ -217,6 +258,7 @@ export class DynamicInput
...this.extraProps,
value: this.value,
onValueChange: (v: unknown) => this.emitValue(v),
+ onOptionsQuery: this.onOptionsQuery,
label: this.label ?? '',
placeholder: this.placeholder ?? '',
required: this.required ?? false,
@@ -265,9 +307,24 @@ export class DynamicInput
instanceObj[prop] = (this as Record
)[prop];
}
}
+ this.applyCallbackProps(instance);
this.applyExtraProps(instance);
}
+ /**
+ * Props that are callbacks rather than resolved values, so they are not in
+ * `KNOWN_PROPS` / `FIELD_RENDERER_PROP_KEYS` and the loop above never sees
+ * them. Without this a renderer declaring `onOptionsQuery` always got
+ * `undefined` and could never trigger a search-remote refetch.
+ */
+ private applyCallbackProps(instance: unknown): void {
+ if (!instance) {
+ return;
+ }
+ (instance as Record)['onOptionsQuery'] =
+ this.onOptionsQuery;
+ }
+
private applyExtraProps(instance: unknown): void {
if (!instance || !this.extraProps) {
return;
@@ -300,6 +357,38 @@ export class DynamicInput
this.onChange.emit(value);
}
+ /**
+ * Puts the aria flags on whatever control the HTML5 fallback just built.
+ *
+ * Applied here, once, rather than in each of the fallback's branches: they
+ * hand-build an input, textarea, checkbox or select, and every one of them
+ * would otherwise need the same four lines. Without this the fallback emits
+ * the `{id}-error` node with nothing pointing at it, and
+ * `focusFirstInvalidField` - which selects `[aria-invalid="true"]` - still
+ * finds nothing on this adapter.
+ *
+ * `render()` re-runs on every ngOnChanges while the fallback is in use (there
+ * is no component instance to sync props into), so this stays current as the
+ * field's error comes and goes.
+ */
+ private applyFallbackAria(): void {
+ const control = (
+ this.host.element.nativeElement as HTMLElement
+ ).querySelector('input, select, textarea');
+ if (!control) {
+ return;
+ }
+ if (this.ariaInvalid !== undefined) {
+ control.setAttribute('aria-invalid', String(this.ariaInvalid));
+ }
+ if (this.ariaRequired !== undefined) {
+ control.setAttribute('aria-required', String(this.ariaRequired));
+ }
+ if (this.ariaDescribedBy) {
+ control.setAttribute('aria-describedby', this.ariaDescribedBy);
+ }
+ }
+
private renderDefaultFallbackHTML5(type: string): boolean {
const nativeEl = this.host.element.nativeElement as HTMLElement;
diff --git a/packages/angular/src/components/FieldInput.ts b/packages/angular/src/components/FieldInput.ts
index eb543ec..1caa022 100644
--- a/packages/angular/src/components/FieldInput.ts
+++ b/packages/angular/src/components/FieldInput.ts
@@ -6,11 +6,16 @@ import {
EventEmitter,
Input,
OnChanges,
+ OnDestroy,
Output,
SimpleChanges,
} from '@angular/core';
import {
buildFieldRendererProps,
+ createOptionsLoader,
+ isAsyncOptions,
+ type OptionsLoader,
+ type OptionsState,
makeFieldId,
FieldDescription,
Properties,
@@ -33,6 +38,8 @@ import { DynamicInput } from './DynamicInput';
[required]="p.required"
[description]="$any(p.description)"
[options]="$any(p.options)"
+ [optionsStatus]="p.optionsStatus"
+ [optionsError]="p.optionsError"
[className]="p.className"
[disabled]="p.disabled"
[readOnly]="p.readOnly"
@@ -53,10 +60,11 @@ import { DynamicInput } from './DynamicInput';
"
(focusout)="onBlurField.emit(fieldDescription!.name)"
[extraProps]="p.extraProps"
+ [onOptionsQuery]="onOptionsQuery"
>
`,
})
-export class FieldInput implements OnChanges {
+export class FieldInput implements OnChanges, OnDestroy {
@Input() fieldDescription?: FieldDescription;
/**
* Data at this field's own level. Preferred over `value`: the shared
@@ -101,11 +109,50 @@ export class FieldInput implements OnChanges {
constructor(private cdr: ChangeDetectorRef) {}
+ // Async options only; a static or synchronous list allocates nothing here.
+ private loader?: OptionsLoader;
+ private optionsState?: OptionsState;
+
+ /** Bound into the template so a renderer can drive a search-remote refetch. */
+ onOptionsQuery = (query: string): void => {
+ this.loader?.setQuery(query);
+ };
+
ngOnChanges(_changes: SimpleChanges): void {
+ this.syncOptionsLoader();
this.rendererProps = this.buildProps();
this.cdr.markForCheck();
}
+ ngOnDestroy(): void {
+ this.loader?.dispose();
+ }
+
+ private syncOptionsLoader(): void {
+ const field = this.fieldDescription;
+ if (!field || !isAsyncOptions(field)) {
+ // A field can be swapped for one with synchronous options. Without this,
+ // the stale optionsState would keep winning in buildFieldRendererProps
+ // (`optionsState ? optionsState.options : resolveOptions(...)`) and the
+ // old async list would be served forever, with the loader never disposed.
+ this.loader?.dispose();
+ this.loader = undefined;
+ this.optionsState = undefined;
+ return;
+ }
+ if (!this.loader) {
+ this.optionsState = { status: 'idle' };
+ this.loader = createOptionsLoader(field, (state) => {
+ this.optionsState = state;
+ this.rendererProps = this.buildProps();
+ // OnPush: an async arrival happens outside any event this view is
+ // checked for, so without this the options would load and never appear.
+ this.cdr.markForCheck();
+ });
+ }
+ this.loader.update(this.data ?? {}, this.rootData);
+ }
+
private buildProps(): ResolvedFieldRendererProps | null {
const field = this.fieldDescription;
if (!field) {
@@ -120,6 +167,7 @@ export class FieldInput implements OnChanges {
id: makeFieldId(field, this.idPrefix),
touched: this.touched,
dirty: this.dirty,
+ optionsState: this.optionsState,
});
// Explicitly bound inputs override what the field description resolves to,
diff --git a/packages/angular/src/components/MultiFieldInput.ts b/packages/angular/src/components/MultiFieldInput.ts
index 8c06392..de45156 100644
--- a/packages/angular/src/components/MultiFieldInput.ts
+++ b/packages/angular/src/components/MultiFieldInput.ts
@@ -136,6 +136,13 @@ function nextInstanceId(): number {
export class MultiFieldInput implements OnInit, OnChanges {
@Input() fieldDescriptions: FieldDescription[] = [];
@Input() properties?: Properties;
+ /**
+ * The values per-field `dirty` is measured against. Defaults to the first
+ * non-`undefined` `properties` this component sees. This adapter has no
+ * `form` shorthand, so pass `store.baselineValues()` here to keep `dirty`
+ * correct across `store.reset(newValues)`.
+ */
+ @Input() initialProperties?: Properties;
@Output() onChange = new EventEmitter();
@Output() validityChange = new EventEmitter();
/**
@@ -171,7 +178,7 @@ export class MultiFieldInput implements OnInit, OnChanges {
// component is client-rendered by the time ids matter, so a module counter
// is enough.
private readonly instanceId = nextInstanceId();
- private initialProperties: Properties = {};
+ private firstSeenProperties: Properties = {};
private indexedErrorsSource?: Record;
private indexedErrors = new Map<
string,
@@ -208,7 +215,8 @@ export class MultiFieldInput implements OnInit, OnChanges {
/** Whether this field's value differs from the one the form opened with. */
isFieldDirty(fieldName: string): boolean {
- return this.data[fieldName] !== this.initialProperties[fieldName];
+ const baseline = this.initialProperties ?? this.firstSeenProperties;
+ return !Object.is(this.data[fieldName], baseline[fieldName]);
}
fieldErrors(fieldName: string): string[] | undefined {
@@ -391,7 +399,7 @@ export class MultiFieldInput implements OnInit, OnChanges {
// Baseline for the `dirty` flag: the values the form opened with, not
// whatever `properties` happens to hold after later edits.
if (!this.initialised) {
- this.initialProperties = { ...this.properties };
+ this.firstSeenProperties = { ...this.properties };
this.initialised = true;
}
}
diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts
index f9c1bd7..bc025a3 100644
--- a/packages/angular/src/lib/dynamic-form.store.ts
+++ b/packages/angular/src/lib/dynamic-form.store.ts
@@ -2,8 +2,11 @@ import { computed, signal } from '@angular/core';
import {
applyComputedValues,
collectFieldPaths,
+ createMessageResolver,
FieldDescription,
+ type MessageCatalog,
Properties,
+ type ValidationContext,
type ValidationResult,
validateFields,
validateFieldsAsync,
@@ -14,6 +17,13 @@ export interface DynamicFormOptions {
initialValues?: Properties;
validateOnBlur?: boolean;
validateOnChange?: boolean;
+ /**
+ * Messages for the built-in validators, set once for the whole form instead
+ * of per field. A message passed directly to a validator still wins, and any
+ * key omitted here falls back to the validator's English default. See core's
+ * `MessageCatalog`.
+ */
+ messages?: MessageCatalog;
}
export function createDynamicFormStore(options: DynamicFormOptions) {
@@ -21,15 +31,22 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
const initialValues = options.initialValues || {};
const validateOnBlur = options.validateOnBlur ?? true;
const validateOnChange = options.validateOnChange ?? false;
+ const validationContext: ValidationContext = {
+ t: createMessageResolver(options.messages),
+ };
const data = signal(applyComputedValues(fields, initialValues));
+ // The baseline `dirty` is measured against: the initialValues option until
+ // reset(newValues) replaces it. Distinct from that option, which never
+ // changes. See the React adapter for the full rationale.
+ const baselineValues = signal({ ...data() });
const errors = signal>({});
const isDirty = signal(false);
const touched = signal>({});
const isSubmitting = signal(false);
const isSubmitted = signal(false);
const validationResult = signal(
- validateFields(fields, data()),
+ validateFields(fields, data(), undefined, validationContext),
);
const isValidating = signal(false);
let validationRun = 0;
@@ -53,7 +70,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
}
function validate(): boolean {
- const res = validateFields(fields, data());
+ const res = validateFields(fields, data(), undefined, validationContext);
errors.set(res.errors);
return commitSyncResult(res);
}
@@ -67,6 +84,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
isValidating.set(true);
try {
const res = await validateFieldsAsync(fields, snapshot, snapshot, {
+ ...validationContext,
signal: controller.signal,
});
if (run !== validationRun || data() !== snapshot) {
@@ -90,7 +108,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
validationRun += 1;
isValidating.set(false);
- const res = validateFields(fields, next);
+ const res = validateFields(fields, next, undefined, validationContext);
commitSyncResult(res);
if (validateOnChange) {
@@ -120,6 +138,18 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
}
/** Clears the touched map without touching data, errors or dirty state. */
+ function getDirtyValues(): Properties {
+ const baseline = baselineValues();
+ const current = data();
+ const dirty: Properties = {};
+ for (const key of Object.keys(current)) {
+ if (!Object.is(current[key], baseline[key])) {
+ dirty[key] = current[key];
+ }
+ }
+ return dirty;
+ }
+
function resetTouched() {
touched.set({});
}
@@ -127,7 +157,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
function handleBlur(fieldName: string) {
setFieldTouched(fieldName, true);
if (validateOnBlur) {
- const res = validateFields(fields, data());
+ const res = validateFields(fields, data(), undefined, validationContext);
errors.set(res.errors);
commitSyncResult(res);
}
@@ -137,6 +167,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
const seed = newValues ?? initialValues;
const next = applyComputedValues(fields, seed);
data.set(next);
+ baselineValues.set({ ...next });
errors.set({});
isDirty.set(false);
touched.set({});
@@ -145,7 +176,9 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
validationController?.abort();
validationRun += 1;
isValidating.set(false);
- commitSyncResult(validateFields(fields, next));
+ commitSyncResult(
+ validateFields(fields, next, undefined, validationContext),
+ );
}
/**
@@ -179,6 +212,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
const snapshot = data();
isValidating.set(true);
const res = await validateFieldsAsync(fields, snapshot, snapshot, {
+ ...validationContext,
signal: controller.signal,
});
if (thisSubmit !== submitRun) {
@@ -192,7 +226,12 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
errors.set(res.errors);
validationResult.set(res);
} else {
- const live = validateFields(fields, data());
+ const live = validateFields(
+ fields,
+ data(),
+ undefined,
+ validationContext,
+ );
errors.set(live.errors);
validationResult.set(live);
}
@@ -219,6 +258,8 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
isValidationComplete,
validationStatus,
isDirty,
+ baselineValues,
+ getDirtyValues,
touched,
isSubmitting,
isSubmitted,
diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts
index f87cfc0..2e848b2 100644
--- a/packages/angular/src/public-api.ts
+++ b/packages/angular/src/public-api.ts
@@ -42,6 +42,15 @@ export type {
ValidationContext,
} from '@dynamic-field-kit/core';
+// Renderer prop contract helpers. `makeErrorId` in particular is what a custom
+// renderer needs to put on its message element so `ariaDescribedBy` resolves.
+export {
+ buildFieldRendererProps,
+ makeErrorId,
+ makeFieldId,
+ FIELD_RENDERER_PROP_KEYS,
+} from '@dynamic-field-kit/core';
+
// Scoped registry: provide FIELD_REGISTRY on a component/route to give that
// subtree an isolated set of renderers.
export { FIELD_REGISTRY } from './fieldRegistryToken';
diff --git a/packages/angular/test/DynamicInput.spec.ts b/packages/angular/test/DynamicInput.spec.ts
index 0d70d66..67b28ee 100644
--- a/packages/angular/test/DynamicInput.spec.ts
+++ b/packages/angular/test/DynamicInput.spec.ts
@@ -341,3 +341,62 @@ describe('DynamicInput', () => {
expect(seen).toEqual([]);
});
});
+
+describe('DynamicInput default renderer error node', () => {
+ let registry: ReturnType;
+
+ beforeEach(() => {
+ // Deliberately empty: nothing registered for 'text', so DynamicInput falls
+ // back to its built-in HTML5 rendering.
+ registry = makeRegistry();
+ TestBed.configureTestingModule({
+ imports: [DynamicInput],
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
+ });
+ });
+
+ it('renders the message with the id ariaDescribedBy points at', () => {
+ const fixture = TestBed.createComponent(DynamicInput);
+ fixture.componentRef.setInput('type', 'text');
+ fixture.componentRef.setInput('id', 'f-title');
+ fixture.componentRef.setInput('error', ['Title is required']);
+ fixture.detectChanges();
+
+ const node: HTMLElement | null =
+ fixture.nativeElement.querySelector('#f-title-error');
+ expect(node).not.toBeNull();
+ expect(node!.textContent).toContain('Title is required');
+ });
+
+ it('renders nothing extra when the field is valid', () => {
+ const fixture = TestBed.createComponent(DynamicInput);
+ fixture.componentRef.setInput('type', 'text');
+ fixture.componentRef.setInput('id', 'f-ok');
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('#f-ok-error')).toBeNull();
+ });
+
+ it('leaves a custom renderer to render its own message', () => {
+ registry.register('text', TextRendererComponent as never);
+ const fixture = TestBed.createComponent(DynamicInput);
+ fixture.componentRef.setInput('type', 'text');
+ fixture.componentRef.setInput('id', 'f-custom');
+ fixture.componentRef.setInput('error', ['Boom']);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('#f-custom-error')).toBeNull();
+ });
+
+ it('renders a bare string error whole, not its first character', () => {
+ const fixture = TestBed.createComponent(DynamicInput);
+ fixture.componentRef.setInput('type', 'text');
+ fixture.componentRef.setInput('id', 'f-str');
+ fixture.componentRef.setInput('error', 'Title is required');
+ fixture.detectChanges();
+
+ const node: HTMLElement | null =
+ fixture.nativeElement.querySelector('#f-str-error');
+ expect(node!.textContent?.trim()).toBe('Title is required');
+ });
+});
diff --git a/packages/angular/test/MultiFieldInput.spec.ts b/packages/angular/test/MultiFieldInput.spec.ts
index 3b6a66c..123d3aa 100644
--- a/packages/angular/test/MultiFieldInput.spec.ts
+++ b/packages/angular/test/MultiFieldInput.spec.ts
@@ -261,3 +261,49 @@ describe('MultiFieldInput', () => {
expect(container.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
});
});
+
+describe('MultiFieldInput dirty baseline', () => {
+ let registry: ReturnType;
+
+ beforeEach(() => {
+ registry = makeRegistry();
+ registry.register('text', TextRendererComponent as never);
+ TestBed.configureTestingModule({
+ imports: [MultiFieldInput],
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
+ });
+ });
+
+ const dirtyFields: FieldDescription[] = [{ name: 'title', type: 'text' }];
+
+ it('is not dirty when properties arrive after mount', () => {
+ const fixture = TestBed.createComponent(MultiFieldInput);
+ fixture.componentRef.setInput('fieldDescriptions', dirtyFields);
+ fixture.componentRef.setInput('properties', undefined);
+ fixture.detectChanges();
+
+ fixture.componentRef.setInput('properties', { title: 'loaded' });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isFieldDirty('title')).toBe(false);
+ });
+
+ it('honours an explicit initialProperties baseline', () => {
+ const fixture = TestBed.createComponent(MultiFieldInput);
+ fixture.componentRef.setInput('fieldDescriptions', dirtyFields);
+ fixture.componentRef.setInput('properties', { title: 'edited' });
+ fixture.componentRef.setInput('initialProperties', { title: 'original' });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isFieldDirty('title')).toBe(true);
+ });
+
+ it('is not dirty against its own opening values', () => {
+ const fixture = TestBed.createComponent(MultiFieldInput);
+ fixture.componentRef.setInput('fieldDescriptions', dirtyFields);
+ fixture.componentRef.setInput('properties', { title: 'original' });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.isFieldDirty('title')).toBe(false);
+ });
+});
diff --git a/packages/angular/test/asyncOptions.spec.ts b/packages/angular/test/asyncOptions.spec.ts
new file mode 100644
index 0000000..89dfeb8
--- /dev/null
+++ b/packages/angular/test/asyncOptions.spec.ts
@@ -0,0 +1,192 @@
+import { Component, Input } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import type {
+ FieldDescription,
+ OptionsContext,
+ Properties,
+} from '@dynamic-field-kit/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { MultiFieldInput } from '../src/components/MultiFieldInput';
+import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
+import { makeRegistry } from './helpers/renderers';
+
+@Component({
+ selector: 'dfk-option-probe',
+ standalone: true,
+ template: `{{ optionsStatus ?? 'none' }}{{ optionValues }}`,
+})
+class OptionProbeComponent {
+ @Input() options?: unknown[];
+ @Input() optionsStatus?: string;
+ @Input() optionsError?: unknown;
+ @Input() onOptionsQuery?: (q: string) => void;
+
+ get optionValues(): string {
+ return ((this.options ?? []) as Properties[])
+ .map((o) => String(o.value))
+ .join(',');
+ }
+}
+
+async function mountField(fields: FieldDescription[]) {
+ const registry = makeRegistry();
+ registry.register('optionProbe' as never, OptionProbeComponent as never);
+ TestBed.configureTestingModule({
+ imports: [MultiFieldInput],
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
+ });
+ const fixture = TestBed.createComponent(MultiFieldInput);
+ fixture.componentRef.setInput('fieldDescriptions', fields);
+ fixture.componentRef.setInput('properties', {});
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ return fixture;
+}
+
+describe('async field options', () => {
+ beforeEach(() => TestBed.resetTestingModule());
+
+ it('goes ready and shows the resolved options', async () => {
+ const fixture = await mountField([
+ {
+ name: 'city',
+ type: 'optionProbe' as never,
+ options: async () => [{ value: 'hn' }, { value: 'sg' }],
+ },
+ ]);
+
+ expect(fixture.nativeElement.querySelector('.status').textContent).toBe(
+ 'ready',
+ );
+ expect(fixture.nativeElement.querySelector('.opts').textContent).toBe(
+ 'hn,sg',
+ );
+ });
+
+ it('reports a failed load', async () => {
+ const fixture = await mountField([
+ {
+ name: 'city',
+ type: 'optionProbe' as never,
+ options: async () => {
+ throw new Error('network down');
+ },
+ },
+ ]);
+
+ expect(fixture.nativeElement.querySelector('.status').textContent).toBe(
+ 'error',
+ );
+ });
+
+ it('leaves a synchronous field with no options status at all', async () => {
+ const fixture = await mountField([
+ {
+ name: 'city',
+ type: 'optionProbe' as never,
+ options: [{ value: 'hn' }],
+ },
+ ]);
+
+ expect(fixture.nativeElement.querySelector('.status').textContent).toBe(
+ 'none',
+ );
+ expect(fixture.nativeElement.querySelector('.opts').textContent).toBe('hn');
+ });
+
+ it('never calls a synchronous options function through the loader', async () => {
+ const load = vi.fn(() => [{ value: 'hn' }]);
+ await mountField([
+ { name: 'city', type: 'optionProbe' as never, options: load },
+ ]);
+
+ expect(load).toHaveBeenCalled();
+ expect(load.mock.calls[0]).toHaveLength(2);
+ });
+});
+
+describe('onOptionsQuery reaches an Angular renderer', () => {
+ beforeEach(() => TestBed.resetTestingModule());
+
+ it('lets a registered renderer trigger a search-remote refetch', async () => {
+ const seen: (string | undefined)[] = [];
+ const registry = makeRegistry();
+ registry.register('optionProbe' as never, OptionProbeComponent as never);
+ TestBed.configureTestingModule({
+ imports: [MultiFieldInput],
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
+ });
+
+ const fixture = TestBed.createComponent(MultiFieldInput);
+ fixture.componentRef.setInput('fieldDescriptions', [
+ {
+ name: 'user',
+ type: 'optionProbe' as never,
+ optionsMode: 'async',
+ options: async (
+ _d: Properties,
+ _r?: Properties,
+ ctx?: OptionsContext,
+ ) => {
+ seen.push(ctx?.query);
+ return [{ value: ctx?.query ?? 'none' }];
+ },
+ },
+ ]);
+ fixture.componentRef.setInput('properties', {});
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const probe = fixture.debugElement.query(
+ (d) => d.componentInstance instanceof OptionProbeComponent,
+ ).componentInstance as OptionProbeComponent;
+
+ // The renderer must actually receive the callback, not undefined.
+ expect(typeof probe.onOptionsQuery).toBe('function');
+
+ probe.onOptionsQuery!('ada');
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(seen).toContain('ada');
+ });
+});
+
+describe('the HTML5 fallback carries the aria flags', () => {
+ beforeEach(() => TestBed.resetTestingModule());
+
+ it('sets aria-invalid and aria-describedby on the built-in control', async () => {
+ const registry = makeRegistry();
+ TestBed.configureTestingModule({
+ imports: [MultiFieldInput],
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
+ });
+
+ const fixture = TestBed.createComponent(MultiFieldInput);
+ fixture.componentRef.setInput('fieldDescriptions', [
+ {
+ name: 'title',
+ type: 'text',
+ required: true,
+ validate: (v: unknown) => (v ? undefined : 'Required'),
+ },
+ ]);
+ fixture.componentRef.setInput('properties', { title: '' });
+ fixture.componentRef.setInput('idPrefix', 'aria');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const input: HTMLElement = fixture.nativeElement.querySelector('input');
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ expect(input.getAttribute('aria-required')).toBe('true');
+ expect(input.getAttribute('aria-describedby')).toBe('aria-title-error');
+ // and the node it points at exists
+ expect(
+ fixture.nativeElement.querySelector('#aria-title-error'),
+ ).not.toBeNull();
+ });
+});
diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts
index 7cd174c..6db4bdf 100644
--- a/packages/angular/test/dynamicFormStore.spec.ts
+++ b/packages/angular/test/dynamicFormStore.spec.ts
@@ -1,4 +1,4 @@
-import { FieldDescription } from '@dynamic-field-kit/core';
+import { FieldDescription, validators } from '@dynamic-field-kit/core';
import { describe, expect, it, vi } from 'vitest';
import { createDynamicFormStore } from '../src/lib/dynamic-form.store';
@@ -258,3 +258,75 @@ describe('Angular Signal DynamicFormStore', () => {
expect(store.isSubmitted()).toBe(false);
});
});
+
+describe('baselineValues and getDirtyValues', () => {
+ const baselineFields: FieldDescription[] = [
+ { name: 'title', type: 'text', label: 'Title' },
+ { name: 'note', type: 'text', label: 'Note' },
+ ];
+
+ it('exposes the initial values as the baseline', () => {
+ const store = createDynamicFormStore({
+ fields: baselineFields,
+ initialValues: { title: 'a', note: 'n' },
+ });
+ expect(store.baselineValues()).toEqual({ title: 'a', note: 'n' });
+ expect(store.getDirtyValues()).toEqual({});
+ });
+
+ it('reports only the changed entries as dirty', () => {
+ const store = createDynamicFormStore({
+ fields: baselineFields,
+ initialValues: { title: 'a', note: 'n' },
+ });
+ store.setFieldValue('title', 'b');
+ expect(store.getDirtyValues()).toEqual({ title: 'b' });
+ });
+
+ it('re-bases the baseline on reset(newValues)', () => {
+ const store = createDynamicFormStore({
+ fields: baselineFields,
+ initialValues: { title: 'a', note: 'n' },
+ });
+ store.setFieldValue('title', 'b');
+ store.reset({ title: 'c', note: 'n' });
+
+ expect(store.baselineValues()).toEqual({ title: 'c', note: 'n' });
+ expect(store.getDirtyValues()).toEqual({});
+ });
+
+ it('restores the original baseline on a bare reset()', () => {
+ const store = createDynamicFormStore({
+ fields: baselineFields,
+ initialValues: { title: 'a', note: 'n' },
+ });
+ store.reset({ title: 'c', note: 'n' });
+ store.reset();
+ expect(store.baselineValues()).toEqual({ title: 'a', note: 'n' });
+ });
+});
+
+describe('messages', () => {
+ const msgFields: FieldDescription[] = [
+ { name: 'title', type: 'text', validate: validators.required() },
+ ];
+
+ it('resolves validator messages through the supplied catalog', () => {
+ const store = createDynamicFormStore({
+ fields: msgFields,
+ initialValues: { title: '' },
+ messages: { required: 'Bắt buộc' },
+ });
+ store.validate();
+ expect(store.errors()['title']).toEqual(['Bắt buộc']);
+ });
+
+ it('keeps the English default with no catalog', () => {
+ const store = createDynamicFormStore({
+ fields: msgFields,
+ initialValues: { title: '' },
+ });
+ store.validate();
+ expect(store.errors()['title']).toEqual(['Field is required']);
+ });
+});
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 2d724df..81ff70b 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,63 @@
# @dynamic-field-kit/core
+## 1.7.0
+
+### Minor Changes
+
+- Dirty-baseline rebasing, accessible validation errors, form-level message catalogs, and async field options across React, Vue, and Angular.
+- 9b06e3f: Validation messages can be set once per form via `useDynamicForm({ messages })`,
+ or process-wide via `setDefaultMessages`, instead of passing a string to every
+ validator on every field. Built-in validators now resolve their message when
+ they run rather than when the field description is built, which is what made a
+ catalog impossible before. A message passed directly to a validator still wins,
+ and the English defaults are unchanged when no catalog is supplied.
+
+ `ValidationContext` - already `validate`'s fourth argument - gains an optional
+ `t` resolver, so a hand-written validator can translate its own messages too.
+
+ Adds `validators.matches(otherFieldName)` for confirm-password and
+ confirm-email fields, which every consumer was hand-writing.
+
+ No locale bundles ship: the mechanism is here, the translations are yours.
+
+- a7358f9: Fix per-field `dirty`, which was measured against a baseline captured at mount
+ and never re-based - wrong after `reset(newValues)` on all three adapters, and
+ wrong on React and Vue for values that arrive after mount, where every field
+ reported dirty forever.
+
+ Adds `baselineValues` and `getDirtyValues()` to the form store on all three
+ adapters, and an `initialProperties` prop to `MultiFieldInput` for re-basing
+ without a store. Comparison moves from `!==` to `Object.is`, so a `NaN` numeric
+ field no longer reads as permanently dirty.
+
+ React's `useDynamicForm` no longer validates the same data twice per change.
+
+- 53ed45a: `options` can now return a promise, covering both dependent selects
+ (`optionsDeps`) and search-remote pickers (`onOptionsQuery`). Renderers receive
+ `optionsStatus` and `optionsError` alongside `options`.
+
+ `debounceMs` was declared on `FieldDescription`, published in the `.d.ts` and
+ read by no implementation anywhere - setting it did nothing. It now debounces
+ these loads.
+
+ Debounce, abort of a superseded request, and discarding a response that lands
+ out of order all live in core's `createOptionsLoader`, so the three adapters
+ share one implementation. Synchronous and static options are untouched and never
+ enter a loading state.
+
+- e35e876: `ariaDescribedBy` is now `${id}-error` when a field has an error instead of
+ being hard-coded `undefined`, and `makeErrorId` is exported so a custom renderer
+ can put the matching id on its message element. Without this,
+ `focusFirstInvalidField` had nothing to find for anyone following the official
+ renderer recipe.
+
+ Default renderers now render the validation message they were already being
+ handed - the one visible change in this release. Custom renderers are untouched,
+ so nobody gets two copies of their own message.
+
+ Development builds now warn when `FieldDescription.props` carries a key the
+ renderer prop contract owns, which 1.6.0 made possible to lose silently.
+
## 1.6.0
### Minor Changes
diff --git a/packages/core/README.md b/packages/core/README.md
index 079b2ac..654a994 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -96,6 +96,10 @@ export interface FieldRendererProps {
dirty?: boolean;
error?: string | string[];
options?: Record[];
+ optionsStatus?: 'idle' | 'loading' | 'ready' | 'error';
+ optionsError?: unknown;
+ /** Not in FIELD_RENDERER_PROP_KEYS - a callback, like onValueChange. */
+ onOptionsQuery?: (query: string) => void;
className?: string;
description?: any;
id?: string;
@@ -133,11 +137,18 @@ let it fall through to a renderer's root element, where Vue assigns
`el.className` — an undefined value becomes `''` and wipes whatever class the
renderer set on itself.
-`ariaDescribedBy` is the one prop no adapter fills in: none of them render the
-description or error node, so pointing `aria-describedby` at an id that may not
-exist would be worse than leaving it unset. It is plumbed through all three so a
-renderer that _does_ render those nodes can set it from `id` (via
-`FieldDescription.props`) and have it arrive the same way everywhere.
+`ariaDescribedBy` used to be the one prop no adapter filled in — it was
+hard-coded `undefined`, on the reasoning that pointing `aria-describedby` at an
+id that might not exist was worse than leaving it unset. That reasoning held,
+but it made `focusFirstInvalidField` (which selects `[aria-invalid="true"]`)
+silently do nothing for every consumer following the renderer recipe, since the
+recipe never mentioned forwarding the aria props either.
+
+Since 1.7.0 it is `makeErrorId(id)` — `` `${id}-error` `` — whenever the field
+has an error, and `undefined` while it is valid. The default renderers render a
+node carrying that id, so the reference resolves for them. A custom renderer
+that forwards `aria-describedby` must put `makeErrorId(id)` on whatever element
+shows its message, or the reference dangles again.
```ts
import { buildFieldRendererProps, makeFieldId } from '@dynamic-field-kit/core';
@@ -307,15 +318,103 @@ const fields: FieldDescription[] = [
- `validators.min(minVal, message?)` - Enforces minimum numeric value
- `validators.max(maxVal, message?)` - Enforces maximum numeric value
- `validators.pattern(regex, message?)` - Enforces regex pattern match
+- `validators.matches(otherFieldName, message?)` - Enforces equality with
+ another field, for confirm-password / confirm-email. Skips empty values so
+ `required` owns that message, and compares with `Object.is` so two `NaN`s match
- `validators.compose(...fns)` - Combines multiple validator functions into one
-`validateFields(fields, data, rootData?)` returns `{ valid, errors, complete,
-status }`, recursing into repeatable groups (keys like `contacts[0].email`) and
+`validateFields(fields, data, rootData?, context?)` returns `{ valid, errors,
+complete, status }`, recursing into repeatable groups (keys like `contacts[0].email`) and
skipping fields that are hidden by `appearCondition` or disabled. Adapters call `validateField` /
`resolveDisabled` / `resolveReadOnly` / `resolveOptions` per field to surface
`error`, `disabled`, `readOnly` and the resolved `options` to renderers
reactively.
+### Validation messages
+
+Built-in validators resolve their message when they **run**, not when the field
+description is built, so a catalog set once for a form reaches all of them:
+
+```ts
+import {
+ createMessageResolver,
+ setDefaultMessages,
+} from '@dynamic-field-kit/core';
+
+// Per form, through an adapter:
+useDynamicForm({ fields, messages: { required: 'Bắt buộc' } });
+
+// Or process-wide, for direct validateFields callers:
+setDefaultMessages({ required: 'Bắt buộc' });
+
+// Or built by hand and passed as the validation context:
+validateFields(fields, data, undefined, {
+ t: createMessageResolver({ required: 'Bắt buộc' }),
+});
+```
+
+Precedence is: a message passed straight to the validator, then the form's
+catalog, then the process-wide one, then the validator's English default.
+
+| Key | Params | English default |
+| ----------- | --------- | ----------------------- |
+| `required` | — | Field is required |
+| `email` | — | Invalid email address |
+| `minLength` | `{min}` | Minimum length is {min} |
+| `maxLength` | `{max}` | Maximum length is {max} |
+| `min` | `{min}` | Minimum value is {min} |
+| `max` | `{max}` | Maximum value is {max} |
+| `pattern` | — | Invalid format |
+| `matches` | `{other}` | Must match {other} |
+
+**No locale bundles ship with this package.** Supply your own catalog. A
+placeholder with no matching param is left verbatim rather than replaced with
+`undefined`, so a typo surfaces as a visible `{unit}`.
+
+`ValidationContext` — already `validate`'s fourth argument, carrying `signal` —
+gains an optional `t`, so a hand-written validator can translate its own
+messages the same way.
+
+### Async options
+
+`options` takes a static array, a synchronous `(data, rootData) => Options[]`,
+or a loader returning a promise. It is one signature, not a union: a union of
+two function types defeats TypeScript's contextual inference, which would make
+every existing `options: (data) => …` an implicit-`any` error.
+
+```ts
+{
+ name: 'city',
+ type: 'select',
+ options: async (data, _rootData, ctx) =>
+ fetch(`/api/cities?country=${data.country}`, { signal: ctx?.signal })
+ .then((r) => r.json()),
+ optionsDeps: (data) => [data.country],
+ debounceMs: 200,
+}
+```
+
+| Property | Effect |
+| ------------- | ------------------------------------------------------------------------------------------ |
+| `optionsDeps` | Values a reload depends on, compared shallowly with `Object.is`. Default `[]` — fetch once |
+| `optionsMode` | `'async'` for a loader returning a promise without the `async` keyword |
+| `debounceMs` | Collapses rapid reloads into one fetch. Async options only |
+
+Renderers receive `optionsStatus` (`'idle' | 'loading' | 'ready' | 'error'`),
+`optionsError`, and `onOptionsQuery(query)` for search-remote fields whose query
+the form data never sees.
+
+`createOptionsLoader(field, onChange)` is the framework-agnostic engine the
+adapters wrap: it debounces, aborts a superseded request through `ctx.signal`,
+and discards a response that lands out of order, so the list always reflects the
+newest request rather than the last to arrive.
+
+Native `async` functions are detected automatically. A loader wrapped in a
+memoiser, a spy or a transpiler helper is **not** — `constructor.name` stops
+being `'AsyncFunction'`. Declare `optionsMode: 'async'` for those; without it the
+promise is dropped and a development warning says so, rather than the renderer
+receiving a pending promise as its option list.
+
### Reading a ValidationResult
```ts
diff --git a/packages/core/package.json b/packages/core/package.json
index aea8e51..784239c 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@dynamic-field-kit/core",
- "version": "1.6.0",
+ "version": "1.7.0",
"description": "Core types and field registry for dynamic-field-kit",
"license": "MIT",
"main": "dist/index.js",
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9cbd407..cd68b7d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -22,3 +22,5 @@ export * from './wizard';
export * from './rendererProps';
export * from './pathMaps';
+export * from './messages';
+export * from './optionsLoader';
diff --git a/packages/core/src/messages.ts b/packages/core/src/messages.ts
new file mode 100644
index 0000000..ae77eab
--- /dev/null
+++ b/packages/core/src/messages.ts
@@ -0,0 +1,87 @@
+import type { Properties, ValidationContext } from './types';
+
+/**
+ * Default messages for the built-in validators, keyed by validator name.
+ * `{name}` placeholders are filled from the params each validator supplies -
+ * `{min}` for `minLength`/`min`, `{max}`, `{other}` for `matches`.
+ *
+ * No locale bundles ship with this library: supply your own catalog, and
+ * anything omitted falls through to the English default baked into the
+ * validator itself.
+ */
+export interface MessageCatalog {
+ required?: string;
+ email?: string;
+ minLength?: string;
+ maxLength?: string;
+ min?: string;
+ max?: string;
+ pattern?: string;
+ matches?: string;
+}
+
+export type MessageResolver = (
+ key: string,
+ params?: Properties,
+) => string | undefined;
+
+function interpolate(template: string, params?: Properties): string {
+ if (!params) {
+ return template;
+ }
+ // An unknown placeholder is left verbatim rather than replaced with
+ // "undefined": a visible `{unit}` in the UI reads as a bug report, whereas
+ // the string "undefined" reads as a mystery.
+ return template.replace(/\{(\w+)\}/g, (match, key: string) =>
+ Object.prototype.hasOwnProperty.call(params, key)
+ ? String(params[key])
+ : match,
+ );
+}
+
+export function createMessageResolver(
+ catalog?: MessageCatalog,
+): MessageResolver {
+ return (key, params) => {
+ const template = catalog?.[key as keyof MessageCatalog];
+ return template === undefined ? undefined : interpolate(template, params);
+ };
+}
+
+let defaultMessages: MessageCatalog | undefined;
+
+/**
+ * A process-wide catalog, for code that calls `validateFields` directly and has
+ * nowhere to thread a context through. A per-form catalog passed to
+ * `useDynamicForm({ messages })` takes precedence over this.
+ */
+export function setDefaultMessages(catalog?: MessageCatalog): void {
+ defaultMessages = catalog;
+}
+
+export function getDefaultMessages(): MessageCatalog | undefined {
+ return defaultMessages;
+}
+
+/**
+ * Message precedence, in one place so every built-in validator agrees:
+ * an explicitly passed message, then this form's catalog, then the global
+ * catalog, then the validator's own English default.
+ */
+export function resolveMessage(
+ ctx: ValidationContext | undefined,
+ key: keyof MessageCatalog,
+ params: Properties | undefined,
+ fallback: string,
+ explicit?: string,
+): string {
+ if (explicit !== undefined) {
+ return explicit;
+ }
+ const fromContext = ctx?.t?.(key, params);
+ if (fromContext !== undefined) {
+ return fromContext;
+ }
+ const fromGlobal = createMessageResolver(defaultMessages)(key, params);
+ return fromGlobal ?? fallback;
+}
diff --git a/packages/core/src/optionsLoader.ts b/packages/core/src/optionsLoader.ts
new file mode 100644
index 0000000..a67213e
--- /dev/null
+++ b/packages/core/src/optionsLoader.ts
@@ -0,0 +1,196 @@
+import type {
+ FieldDescription,
+ OptionsFn,
+ OptionsStatus,
+ Properties,
+} from './types';
+
+export interface OptionsState {
+ status: OptionsStatus;
+ options?: Properties[];
+ error?: unknown;
+}
+
+export interface OptionsLoader {
+ /**
+ * Re-evaluates `optionsDeps` against the current data and fetches only when
+ * they actually changed. Safe to call on every render or keystroke.
+ */
+ update(data: Properties, rootData?: Properties): void;
+ /**
+ * Renderer-driven refetch for a search-remote field. Always fetches (after
+ * the debounce), because the query is state the form data never sees.
+ */
+ setQuery(query: string): void;
+ current(): OptionsState;
+ /** Aborts anything in flight and stops further callbacks. */
+ dispose(): void;
+}
+
+/**
+ * Whether this field's options are loaded asynchronously.
+ *
+ * Detected the same way `validationMode` detects async validators
+ * (`constructor.name === 'AsyncFunction'`), with `optionsMode: 'async'` as the
+ * explicit escape hatch for a function that returns a promise without the
+ * `async` keyword.
+ */
+export function isAsyncOptions(field: FieldDescription): boolean {
+ // A loader has to be callable before any of the rest matters. Without this,
+ // `optionsMode: 'async'` alongside a static array would send the field down
+ // the loader path and throw `load is not a function` out of a React effect,
+ // a Vue watcher or Angular's ngOnChanges - crashing the render rather than
+ // degrading to the synchronous path.
+ if (typeof field.options !== 'function') {
+ return false;
+ }
+ if (field.optionsMode === 'async') {
+ return true;
+ }
+ if (field.optionsMode === 'sync') {
+ return false;
+ }
+ return field.options.constructor?.name === 'AsyncFunction';
+}
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === 'AbortError';
+}
+
+function sameDeps(left: unknown[], right: unknown[]): boolean {
+ return (
+ left.length === right.length &&
+ left.every((value, index) => Object.is(value, right[index]))
+ );
+}
+
+/**
+ * Owns everything hard about loading a field's options asynchronously:
+ * debouncing, aborting a superseded run, discarding a response that lands out
+ * of order, and deciding whether the dependencies actually changed.
+ *
+ * Framework-agnostic on purpose. Each adapter wraps this in its own reactivity
+ * primitive and forwards the state as renderer props, so the logic exists once
+ * rather than three times.
+ */
+export function createOptionsLoader(
+ field: FieldDescription,
+ onChange: (state: OptionsState) => void,
+): OptionsLoader {
+ let state: OptionsState = { status: 'idle' };
+ let disposed = false;
+
+ // Incremented per fetch. A response whose run is stale is dropped even if the
+ // abort did not take - a fetch implementation is free to ignore the signal,
+ // and this is the check that does not depend on it cooperating.
+ let run = 0;
+ let controller: AbortController | undefined;
+ let timer: ReturnType | undefined;
+
+ let lastDeps: unknown[] | undefined;
+ let currentQuery: string | undefined;
+ let latestData: Properties = {};
+ let latestRootData: Properties | undefined;
+
+ function emit(next: OptionsState): void {
+ if (disposed) {
+ return;
+ }
+ state = next;
+ onChange(state);
+ }
+
+ function fetchNow(): void {
+ if (disposed) {
+ return;
+ }
+ const thisRun = ++run;
+ controller?.abort();
+ const thisController = new AbortController();
+ controller = thisController;
+
+ // Keep the previous options visible while reloading (a list that blinks to
+ // empty on every keystroke is worse than a stale one), but drop any error
+ // from the last attempt - `loading` alongside a stale `optionsError` is a
+ // state no renderer should have to reason about.
+ emit({ status: 'loading', options: state.options });
+
+ const load = field.options as OptionsFn;
+ Promise.resolve(
+ load(latestData, latestRootData, {
+ query: currentQuery,
+ signal: thisController.signal,
+ }),
+ ).then(
+ (options) => {
+ if (thisRun !== run) {
+ return;
+ }
+ emit({ status: 'ready', options });
+ },
+ (error: unknown) => {
+ if (thisRun !== run) {
+ return;
+ }
+ // Being superseded is normal, not a failure. Reporting it as one would
+ // flash an error in the UI on every keystroke of a search box.
+ if (isAbortError(error)) {
+ return;
+ }
+ emit({ status: 'error', error, options: state.options });
+ },
+ );
+ }
+
+ function schedule(): void {
+ if (disposed) {
+ return;
+ }
+ const wait = field.debounceMs ?? 0;
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ if (wait <= 0) {
+ // Not setTimeout(0): the undebounced case should not wait on the timer
+ // queue, which in a test with fake timers would never fire at all.
+ fetchNow();
+ return;
+ }
+ timer = setTimeout(() => {
+ timer = undefined;
+ fetchNow();
+ }, wait);
+ }
+
+ return {
+ update(data, rootData) {
+ latestData = data;
+ latestRootData = rootData;
+ const deps = field.optionsDeps?.(data, rootData) ?? [];
+ if (lastDeps !== undefined && sameDeps(lastDeps, deps)) {
+ return;
+ }
+ lastDeps = deps;
+ schedule();
+ },
+
+ setQuery(query) {
+ currentQuery = query;
+ schedule();
+ },
+
+ current() {
+ return state;
+ },
+
+ dispose() {
+ disposed = true;
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ controller?.abort();
+ },
+ };
+}
diff --git a/packages/core/src/rendererProps.ts b/packages/core/src/rendererProps.ts
index 9fb0db4..adf8ccf 100644
--- a/packages/core/src/rendererProps.ts
+++ b/packages/core/src/rendererProps.ts
@@ -1,3 +1,4 @@
+import type { OptionsState } from './optionsLoader';
import type { FieldDescription, FieldRendererProps, Properties } from './types';
import {
resolveDisabled,
@@ -28,6 +29,8 @@ export const FIELD_RENDERER_PROP_KEYS = [
'dirty',
'error',
'options',
+ 'optionsStatus',
+ 'optionsError',
'className',
'description',
'id',
@@ -43,6 +46,59 @@ export const FIELD_RENDERER_PROP_KEYS = [
export type FieldRendererPropKey = (typeof FIELD_RENDERER_PROP_KEYS)[number];
+function isDev(): boolean {
+ return (
+ typeof process !== 'undefined' &&
+ !!process.env &&
+ process.env.NODE_ENV !== 'production'
+ );
+}
+
+const RESERVED_PROP_KEYS: ReadonlySet = new Set(
+ FIELD_RENDERER_PROP_KEYS,
+);
+const warnedReservedProps = new Set();
+
+/** Test-only. Clears the warn-once memo so each case starts from silence. */
+export function __resetReservedPropWarnings(): void {
+ warnedReservedProps.clear();
+}
+
+/**
+ * `props` is spread *before* the resolved contract in every adapter, so a key
+ * the contract owns is silently overwritten - usually by `undefined`, which is
+ * indistinguishable from the value simply vanishing. Nothing throws, so this
+ * warning is the only signal a consumer gets.
+ *
+ * Fires once per field+key: a form re-renders constantly, and a console filled
+ * with the same line is a console nobody reads.
+ */
+function warnOnReservedProps(
+ fieldName: string,
+ extraProps: Properties | undefined,
+): void {
+ if (!isDev() || !extraProps) {
+ return;
+ }
+ for (const key of Object.keys(extraProps)) {
+ if (!RESERVED_PROP_KEYS.has(key)) {
+ continue;
+ }
+ const memo = `${fieldName}.${key}`;
+ if (warnedReservedProps.has(memo)) {
+ continue;
+ }
+ warnedReservedProps.add(memo);
+ console.warn(
+ `[dynamic-field-kit] field "${fieldName}" passes "${key}" through ` +
+ `\`props\`, but "${key}" is part of the renderer prop contract and is ` +
+ `resolved from the field description itself, so the value in \`props\` ` +
+ `is discarded. Move it to the top level: ` +
+ `{ name: "${fieldName}", ${key}: ... }.`,
+ );
+ }
+}
+
/**
* A fully resolved renderer prop bag, plus the two keys the adapter layer needs
* but the renderer never sees as-is: `type` (which renderer to look up) and
@@ -61,6 +117,12 @@ export interface BuildFieldRendererPropsInput {
rootData?: Properties;
/** Resolved DOM id for this field - see `makeFieldId`. */
id: string;
+ /**
+ * Current state of an async options load, from `createOptionsLoader`.
+ * Omitted for static or synchronous options, where there is nothing to wait
+ * for and `resolveOptions` already has the answer.
+ */
+ optionsState?: OptionsState;
touched?: boolean;
dirty?: boolean;
/**
@@ -85,6 +147,17 @@ export function makeFieldId(
return fieldDescription.id ?? `${prefix}-${fieldDescription.name}`;
}
+/**
+ * The id of the node that renders a field's validation message.
+ *
+ * `ariaDescribedBy` points here, so a renderer that forwards it must put this
+ * id on whatever element shows the error - otherwise the reference dangles and
+ * assistive technology has nothing to read.
+ */
+export function makeErrorId(id: string): string {
+ return `${id}-error`;
+}
+
/**
* Builds the complete renderer prop bag for one field. Shared by the React,
* Vue and Angular adapters so all three forward an identical set.
@@ -97,6 +170,7 @@ export function buildFieldRendererProps({
touched,
dirty,
validationErrors,
+ optionsState,
}: BuildFieldRendererPropsInput): ResolvedFieldRendererProps {
const {
name,
@@ -114,9 +188,14 @@ export function buildFieldRendererProps({
props: extraProps,
} = fieldDescription;
+ warnOnReservedProps(name, extraProps);
+
const disabled = resolveDisabled(fieldDescription, data, rootData);
const readOnly = resolveReadOnly(fieldDescription, data, rootData);
- const options = resolveOptions(fieldDescription, data, rootData);
+ // An async loader owns the list; resolveOptions returns undefined for those.
+ const options = optionsState
+ ? optionsState.options
+ : resolveOptions(fieldDescription, data, rootData);
// A disabled field is not submitted, so validating it would surface an error
// the user cannot act on.
@@ -138,15 +217,17 @@ export function buildFieldRendererProps({
dirty,
error,
options,
+ optionsStatus: optionsState?.status,
+ optionsError: optionsState?.error,
className,
description,
id,
ariaInvalid: Boolean(error),
- // Left undefined on purpose: the adapters do not render the description or
- // error node themselves, so pointing aria-describedby at an id that may not
- // exist would be worse than omitting it. A renderer that does render those
- // nodes can set it from `id`.
- ariaDescribedBy: undefined,
+ // Points at the error node, but only when there is an error to point at.
+ // The adapters render that node for default renderers; a custom renderer
+ // that forwards this prop must put `makeErrorId(id)` on its own message
+ // element, or the reference dangles.
+ ariaDescribedBy: error ? makeErrorId(id) : undefined,
ariaRequired: Boolean(required),
min,
max,
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 403b3c0..92299a6 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -20,9 +20,49 @@ export interface FieldTypeMap {
export type Properties = Record;
+/** The third argument an options loader receives, for async loading. */
+export interface OptionsContext {
+ /**
+ * Whatever the renderer last passed to `onOptionsQuery` - the search box in
+ * a search-remote picker. Undefined for a purely data-driven load.
+ */
+ query?: string;
+ /** Aborted when a newer load supersedes this one. */
+ signal: AbortSignal;
+}
+
+/**
+ * Resolves a field's options.
+ *
+ * One signature rather than a union of a sync and an async shape, and the
+ * positional `(data, rootData)` is unchanged from before async loading
+ * existed. Both of those are deliberate: a union of two function types
+ * defeats TypeScript's contextual inference, so every existing
+ * `options: (data) => …` would have started erroring under `noImplicitAny`.
+ * Returning a promise is what makes a loader async, not its parameter shape.
+ */
+export type OptionsFn = (
+ data: Properties,
+ rootData?: Properties,
+ ctx?: OptionsContext,
+) => Properties[] | Promise;
+
+export type OptionsStatus = 'idle' | 'loading' | 'ready' | 'error';
+
export interface ValidationContext {
/** Aborted when a newer validation run supersedes this one. */
signal?: AbortSignal;
+ /**
+ * Resolves a validator's message key against the catalog in effect for this
+ * form, returning undefined for a key the catalog omits so the validator
+ * falls back to its own default. Supplied by the adapters from
+ * `useDynamicForm({ messages })`.
+ *
+ * It lives here rather than in a parameter of its own because `validate`
+ * already receives this context as its fourth argument - one object carries
+ * both concerns, and an async validator gets the resolver for free.
+ */
+ t?: (key: string, params?: Properties) => string | undefined;
}
export interface FieldRendererProps {
@@ -38,6 +78,22 @@ export interface FieldRendererProps {
dirty?: boolean;
error?: string | string[];
options?: Properties[];
+ /**
+ * Where the option list currently stands. Only ever set for a field with an
+ * async loader; undefined means the options are static or synchronous and
+ * there is nothing to wait for.
+ */
+ optionsStatus?: OptionsStatus;
+ /** Whatever the async loader rejected with, when `optionsStatus` is 'error'. */
+ optionsError?: unknown;
+ /**
+ * Ask for a fresh option list matching `query` - the search box in a
+ * search-remote picker. Debounced by the field's `debounceMs`.
+ *
+ * Not part of `FIELD_RENDERER_PROP_KEYS`: it is a callback, attached by the
+ * adapter alongside `onValueChange` and `onBlur`.
+ */
+ onOptionsQuery?: (query: string) => void;
className?: string;
description?: unknown;
id?: string;
@@ -102,14 +158,36 @@ export interface FieldDescription {
disabledCondition?: (data: Properties, rootData?: Properties) => boolean;
/** Dynamic read-only state. */
readOnlyCondition?: (data: Properties, rootData?: Properties) => boolean;
- /** Dynamic options list or static mảng options. */
- options?:
- Properties[] | ((data: Properties, rootData?: Properties) => Properties[]);
+ /**
+ * A static list, a synchronous function of the form data, or an
+ * asynchronous loader. Declare `optionsMode: 'async'` for a loader that
+ * returns a promise without the `async` keyword, the way `validationMode`
+ * works.
+ */
+ options?: Properties[] | OptionsFn;
+ /** Mirrors `validationMode`, for the options loader. */
+ optionsMode?: 'sync' | 'async';
+ /**
+ * Values an async loader depends on. It refetches when any of them changes,
+ * compared shallowly with `Object.is`. Defaults to `[]`, meaning fetch once:
+ * without this the loader would have to refetch on every keystroke in the
+ * whole form, since it cannot see what the loader function reads.
+ *
+ * Ignored for synchronous options.
+ */
+ optionsDeps?: (data: Properties, rootData?: Properties) => unknown[];
min?: number | string;
max?: number | string;
step?: number | string;
accept?: string;
multiple?: boolean;
+ /**
+ * Debounce for the async options loader, in milliseconds. Rapid `update` or
+ * `onOptionsQuery` calls inside the window collapse into one fetch.
+ *
+ * Ignored for synchronous options. Before 1.7.0 this was declared but read
+ * by nothing at all - setting it did nothing.
+ */
debounceMs?: number;
className?: string;
description?: unknown;
diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts
index 4d60e13..7ca62d7 100644
--- a/packages/core/src/validation.ts
+++ b/packages/core/src/validation.ts
@@ -1,4 +1,5 @@
import { isFieldGroup } from './fieldGroup';
+import { isAsyncOptions } from './optionsLoader';
import type { FieldDescription, Properties, ValidationContext } from './types';
export interface ValidationResult {
@@ -57,6 +58,28 @@ function warnAsyncValidator(key: string): void {
);
}
+const warnedUndeclaredAsyncOptions = new Set();
+
+/** Test-only. Clears the warn-once memo so each case starts from silence. */
+export function __resetOptionsWarnings(): void {
+ warnedUndeclaredAsyncOptions.clear();
+}
+
+function warnUndeclaredAsyncOptions(name: string): void {
+ if (!isDev() || warnedUndeclaredAsyncOptions.has(name)) {
+ return;
+ }
+ warnedUndeclaredAsyncOptions.add(name);
+ console.warn(
+ `[dynamic-field-kit] the options function for "${name}" returned a ` +
+ `Promise, but the field is not declared async, so its options were ` +
+ `dropped rather than handed to the renderer as a pending promise. ` +
+ `Native async functions are detected automatically; a loader wrapped ` +
+ `in a memoiser, a spy or a transpiler helper is not. Add ` +
+ `\`optionsMode: 'async'\` to the field.`,
+ );
+}
+
function isPromiseLike(value: unknown): value is PromiseLike {
return (
(typeof value === 'object' || typeof value === 'function') &&
@@ -97,17 +120,33 @@ export function resolveReadOnly(
return field.readOnlyCondition?.(data, rootData) === true;
}
-/** Resolves dynamic options or returns static options list. */
+/**
+ * Resolves a static or synchronous options list.
+ *
+ * Returns undefined for a field whose options load asynchronously: those are
+ * owned by `createOptionsLoader`, and calling the function here would hand the
+ * renderer a Promise as its `options`.
+ */
export function resolveOptions(
field: FieldDescription,
data: Properties,
rootData?: Properties,
): Properties[] | undefined {
- if (!field.options) {
+ if (!field.options || isAsyncOptions(field)) {
return undefined;
}
if (typeof field.options === 'function') {
- return field.options(data, rootData);
+ const result = field.options(data, rootData);
+ if (isPromiseLike(result)) {
+ // Detection missed it: `constructor.name` is not 'AsyncFunction' for a
+ // loader wrapped in a memoiser, a spy, or a transpiler's helper. Handing
+ // the renderer this promise as its option list would be worse than an
+ // empty list, so drop it and say what to do about it.
+ void Promise.resolve(result).catch(() => undefined);
+ warnUndeclaredAsyncOptions(field.name);
+ return undefined;
+ }
+ return result;
}
return field.options;
}
@@ -124,6 +163,7 @@ function runSyncValidate(
rootData: Properties | undefined,
/** Key to report this field under - a grouped field is not just `name`. */
reportKey = field.name,
+ context?: ValidationContext,
): { errors: string[]; isPending: boolean } {
if (!field.validate) {
return { errors: [], isPending: false };
@@ -136,7 +176,7 @@ function runSyncValidate(
}
return { errors: [], isPending: true };
}
- const result = field.validate(value, data, rootData);
+ const result = field.validate(value, data, rootData, context);
if (isPromiseLike(result)) {
// A rejected async result has no observer on the synchronous path. Attach
// one so live validation does not create an unhandled rejection; callers
@@ -160,8 +200,10 @@ export function validateField(
value: unknown,
data: Properties,
rootData?: Properties,
+ context?: ValidationContext,
): string[] {
- return runSyncValidate(field, value, data, rootData).errors;
+ return runSyncValidate(field, value, data, rootData, field.name, context)
+ .errors;
}
/** Run one field's validate hook asynchronously; always returns a Promise resolving to string[]. */
@@ -192,6 +234,7 @@ export function validateFields(
fields: FieldDescription[],
data: Properties,
rootData: Properties = data,
+ context?: ValidationContext,
): ValidationResult {
const errors: Record = {};
const pending: string[] = [];
@@ -209,7 +252,7 @@ export function validateFields(
? (data[field.name] as Properties[])
: [];
items.forEach((item, index) => {
- const sub = validateFields(field.fields, item, rootData);
+ const sub = validateFields(field.fields, item, rootData, context);
for (const [key, messages] of Object.entries(sub.errors)) {
errors[`${field.name}[${index}].${key}`] = messages;
}
@@ -225,6 +268,8 @@ export function validateFields(
data[field.name],
data,
rootData,
+ field.name,
+ context,
);
if (fieldErrors.length > 0) {
errors[field.name] = fieldErrors;
diff --git a/packages/core/src/validators.ts b/packages/core/src/validators.ts
index 58feb71..c93fcbf 100644
--- a/packages/core/src/validators.ts
+++ b/packages/core/src/validators.ts
@@ -1,36 +1,52 @@
-import type { Properties } from './types';
+import { resolveMessage } from './messages';
+import type { Properties, ValidationContext } from './types';
export type ValidatorFn = (
value: unknown,
data?: Properties,
rootData?: Properties,
+ ctx?: ValidationContext,
) => string | undefined;
+function isEmpty(value: unknown): boolean {
+ return value === undefined || value === null || value === '';
+}
+
export const validators = {
/** Enforces that a value is non-empty (not undefined, null, empty string, or empty array). */
- required(message = 'Field is required'): ValidatorFn {
- return (value: unknown) => {
- if (
- value === undefined ||
- value === null ||
- value === '' ||
- (Array.isArray(value) && value.length === 0)
- ) {
- return message;
+ required(message?: string): ValidatorFn {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value) || (Array.isArray(value) && value.length === 0)) {
+ // Resolved here, inside the closure, rather than when the field
+ // description is built: a catalog supplied to the form could never
+ // reach a message baked in at definition time.
+ return resolveMessage(
+ ctx,
+ 'required',
+ undefined,
+ 'Field is required',
+ message,
+ );
}
return undefined;
};
},
/** Enforces a valid email format. */
- email(message = 'Invalid email address'): ValidatorFn {
+ email(message?: string): ValidatorFn {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- return (value: unknown) => {
- if (value === undefined || value === null || value === '') {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value)) {
return undefined;
}
if (typeof value !== 'string' || !emailRegex.test(value)) {
- return message;
+ return resolveMessage(
+ ctx,
+ 'email',
+ undefined,
+ 'Invalid email address',
+ message,
+ );
}
return undefined;
};
@@ -38,14 +54,19 @@ export const validators = {
/** Enforces minimum string length or array length. */
minLength(min: number, message?: string): ValidatorFn {
- const msg = message ?? `Minimum length is ${min}`;
- return (value: unknown) => {
- if (value === undefined || value === null || value === '') {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value)) {
return undefined;
}
if (typeof value === 'string' || Array.isArray(value)) {
if (value.length < min) {
- return msg;
+ return resolveMessage(
+ ctx,
+ 'minLength',
+ { min },
+ `Minimum length is ${min}`,
+ message,
+ );
}
}
return undefined;
@@ -54,14 +75,19 @@ export const validators = {
/** Enforces maximum string length or array length. */
maxLength(max: number, message?: string): ValidatorFn {
- const msg = message ?? `Maximum length is ${max}`;
- return (value: unknown) => {
- if (value === undefined || value === null || value === '') {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value)) {
return undefined;
}
if (typeof value === 'string' || Array.isArray(value)) {
if (value.length > max) {
- return msg;
+ return resolveMessage(
+ ctx,
+ 'maxLength',
+ { max },
+ `Maximum length is ${max}`,
+ message,
+ );
}
}
return undefined;
@@ -70,14 +96,19 @@ export const validators = {
/** Enforces minimum numerical value. */
min(minVal: number, message?: string): ValidatorFn {
- const msg = message ?? `Minimum value is ${minVal}`;
- return (value: unknown) => {
- if (value === undefined || value === null || value === '') {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value)) {
return undefined;
}
const num = Number(value);
if (Number.isNaN(num) || num < minVal) {
- return msg;
+ return resolveMessage(
+ ctx,
+ 'min',
+ { min: minVal },
+ `Minimum value is ${minVal}`,
+ message,
+ );
}
return undefined;
};
@@ -85,32 +116,67 @@ export const validators = {
/** Enforces maximum numerical value. */
max(maxVal: number, message?: string): ValidatorFn {
- const msg = message ?? `Maximum value is ${maxVal}`;
- return (value: unknown) => {
- if (value === undefined || value === null || value === '') {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value)) {
return undefined;
}
const num = Number(value);
if (Number.isNaN(num) || num > maxVal) {
- return msg;
+ return resolveMessage(
+ ctx,
+ 'max',
+ { max: maxVal },
+ `Maximum value is ${maxVal}`,
+ message,
+ );
}
return undefined;
};
},
/** Enforces a regex pattern. */
- pattern(regex: RegExp, message = 'Invalid format'): ValidatorFn {
- return (value: unknown) => {
- if (value === undefined || value === null || value === '') {
+ pattern(regex: RegExp, message?: string): ValidatorFn {
+ return (value, _data, _rootData, ctx) => {
+ if (isEmpty(value)) {
return undefined;
}
if (typeof value !== 'string' || !regex.test(value)) {
- return message;
+ return resolveMessage(
+ ctx,
+ 'pattern',
+ undefined,
+ 'Invalid format',
+ message,
+ );
}
return undefined;
};
},
+ /**
+ * Enforces that this field equals another field's value - confirm-password,
+ * confirm-email. Skips empty values so `required` owns that message rather
+ * than both firing at once.
+ */
+ matches(otherFieldName: string, message?: string): ValidatorFn {
+ return (value, data, _rootData, ctx) => {
+ if (isEmpty(value)) {
+ return undefined;
+ }
+ // Object.is, not !==: two NaNs are the same value for this purpose.
+ if (Object.is(value, data?.[otherFieldName])) {
+ return undefined;
+ }
+ return resolveMessage(
+ ctx,
+ 'matches',
+ { other: otherFieldName },
+ `Must match ${otherFieldName}`,
+ message,
+ );
+ };
+ },
+
/** Combines multiple validator functions into a single field validator. */
compose(
...fns: ValidatorFn[]
@@ -118,11 +184,17 @@ export const validators = {
value: unknown,
data: Properties,
rootData?: Properties,
+ ctx?: ValidationContext,
) => string[] | undefined {
- return (value: unknown, data: Properties, rootData?: Properties) => {
+ return (
+ value: unknown,
+ data: Properties,
+ rootData?: Properties,
+ ctx?: ValidationContext,
+ ) => {
const errors: string[] = [];
for (const fn of fns) {
- const err = fn(value, data, rootData);
+ const err = fn(value, data, rootData, ctx);
if (err) {
errors.push(err);
}
diff --git a/packages/core/test/messages.test.ts b/packages/core/test/messages.test.ts
new file mode 100644
index 0000000..9710238
--- /dev/null
+++ b/packages/core/test/messages.test.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ createMessageResolver,
+ getDefaultMessages,
+ resolveMessage,
+ setDefaultMessages,
+} from '../src/messages';
+
+afterEach(() => setDefaultMessages(undefined));
+
+describe('createMessageResolver', () => {
+ it('returns the catalog entry for a known key', () => {
+ const t = createMessageResolver({ required: 'Bắt buộc' });
+ expect(t('required')).toBe('Bắt buộc');
+ });
+
+ it('returns undefined for a key the catalog omits', () => {
+ const t = createMessageResolver({ required: 'Bắt buộc' });
+ expect(t('email')).toBeUndefined();
+ });
+
+ it('interpolates named params', () => {
+ const t = createMessageResolver({ minLength: 'Tối thiểu {min} ký tự' });
+ expect(t('minLength', { min: 8 })).toBe('Tối thiểu 8 ký tự');
+ });
+
+ it('leaves an unknown placeholder alone rather than printing undefined', () => {
+ const t = createMessageResolver({ minLength: 'At least {min} of {unit}' });
+ expect(t('minLength', { min: 8 })).toBe('At least 8 of {unit}');
+ });
+
+ it('with no catalog resolves nothing', () => {
+ const t = createMessageResolver();
+ expect(t('required')).toBeUndefined();
+ });
+});
+
+describe('setDefaultMessages', () => {
+ it('is read back by getDefaultMessages', () => {
+ setDefaultMessages({ required: 'Global' });
+ expect(getDefaultMessages()).toEqual({ required: 'Global' });
+ });
+
+ it('is cleared by passing undefined', () => {
+ setDefaultMessages({ required: 'Global' });
+ setDefaultMessages(undefined);
+ expect(getDefaultMessages()).toBeUndefined();
+ });
+});
+
+describe('resolveMessage precedence', () => {
+ it('prefers an explicitly passed message over everything', () => {
+ setDefaultMessages({ required: 'Global' });
+ const ctx = { t: createMessageResolver({ required: 'Scoped' }) };
+ expect(
+ resolveMessage(ctx, 'required', undefined, 'English', 'Explicit'),
+ ).toBe('Explicit');
+ });
+
+ it('prefers the context resolver over the global default', () => {
+ setDefaultMessages({ required: 'Global' });
+ const ctx = { t: createMessageResolver({ required: 'Scoped' }) };
+ expect(resolveMessage(ctx, 'required', undefined, 'English')).toBe(
+ 'Scoped',
+ );
+ });
+
+ it('falls back to the global default when the context has no resolver', () => {
+ setDefaultMessages({ required: 'Global' });
+ expect(resolveMessage(undefined, 'required', undefined, 'English')).toBe(
+ 'Global',
+ );
+ });
+
+ it('falls back to the hard-coded English when nothing is configured', () => {
+ expect(resolveMessage(undefined, 'required', undefined, 'English')).toBe(
+ 'English',
+ );
+ });
+});
diff --git a/packages/core/test/optionsLoader.test.ts b/packages/core/test/optionsLoader.test.ts
new file mode 100644
index 0000000..36a54c9
--- /dev/null
+++ b/packages/core/test/optionsLoader.test.ts
@@ -0,0 +1,362 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ createOptionsLoader,
+ isAsyncOptions,
+ type OptionsState,
+} from '../src/optionsLoader';
+import type { FieldDescription, Properties } from '../src/types';
+import { __resetOptionsWarnings, resolveOptions } from '../src/validation';
+
+const OPTIONS: Properties[] = [{ label: 'Hanoi', value: 'hn' }];
+
+function collect() {
+ const states: OptionsState[] = [];
+ return { states, onChange: (s: OptionsState) => states.push({ ...s }) };
+}
+
+describe('isAsyncOptions', () => {
+ it('is false for a static array', () => {
+ expect(isAsyncOptions({ name: 'a', type: 'text', options: OPTIONS })).toBe(
+ false,
+ );
+ });
+
+ it('is false for a synchronous function', () => {
+ expect(
+ isAsyncOptions({ name: 'a', type: 'text', options: () => OPTIONS }),
+ ).toBe(false);
+ });
+
+ it('is true for a native async function', () => {
+ expect(
+ isAsyncOptions({
+ name: 'a',
+ type: 'text',
+ options: async () => OPTIONS,
+ }),
+ ).toBe(true);
+ });
+
+ it('honours an explicit optionsMode for a promise-returning non-async fn', () => {
+ expect(
+ isAsyncOptions({
+ name: 'a',
+ type: 'text',
+ optionsMode: 'async',
+ options: () => Promise.resolve(OPTIONS),
+ }),
+ ).toBe(true);
+ });
+});
+
+describe('resolveOptions leaves async loaders alone', () => {
+ it('returns undefined rather than handing the renderer a promise', () => {
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async () => OPTIONS,
+ };
+ expect(resolveOptions(field, {})).toBeUndefined();
+ });
+
+ it('still resolves a synchronous function', () => {
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: () => OPTIONS,
+ };
+ expect(resolveOptions(field, {})).toEqual(OPTIONS);
+ });
+});
+
+describe('createOptionsLoader', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('fetches once and reports idle -> loading -> ready', async () => {
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async () => OPTIONS,
+ };
+ const { states, onChange } = collect();
+ const loader = createOptionsLoader(field, onChange);
+
+ expect(loader.current().status).toBe('idle');
+ loader.update({});
+ expect(states.map((s) => s.status)).toEqual(['loading']);
+
+ await vi.runAllTimersAsync();
+
+ expect(states.map((s) => s.status)).toEqual(['loading', 'ready']);
+ expect(loader.current().options).toEqual(OPTIONS);
+ });
+
+ it('collapses calls inside the debounce window into one fetch', async () => {
+ const load = vi.fn(async () => OPTIONS);
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: load,
+ debounceMs: 100,
+ optionsDeps: (data) => [data.country],
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({ country: 'a' });
+ loader.update({ country: 'b' });
+ loader.update({ country: 'c' });
+
+ await vi.advanceTimersByTimeAsync(150);
+
+ expect(load).toHaveBeenCalledTimes(1);
+ expect(loader.current().options).toEqual(OPTIONS);
+ });
+
+ it('does not refetch when the deps are unchanged', async () => {
+ const load = vi.fn(async () => OPTIONS);
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: load,
+ optionsDeps: (data) => [data.country],
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({ country: 'vn', unrelated: 1 });
+ await vi.runAllTimersAsync();
+ loader.update({ country: 'vn', unrelated: 2 });
+ await vi.runAllTimersAsync();
+
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetches when the deps change', async () => {
+ const load = vi.fn(async () => OPTIONS);
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: load,
+ optionsDeps: (data) => [data.country],
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({ country: 'vn' });
+ await vi.runAllTimersAsync();
+ loader.update({ country: 'us' });
+ await vi.runAllTimersAsync();
+
+ expect(load).toHaveBeenCalledTimes(2);
+ });
+
+ it('fetches exactly once with no optionsDeps declared', async () => {
+ const load = vi.fn(async () => OPTIONS);
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: load,
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({ a: 1 });
+ loader.update({ a: 2 });
+ await vi.runAllTimersAsync();
+
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it('aborts the previous run when a newer one starts', async () => {
+ const signals: AbortSignal[] = [];
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async (_data, _root, ctx) => {
+ signals.push(ctx!.signal);
+ return OPTIONS;
+ },
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.setQuery('a');
+ loader.setQuery('b');
+ await vi.runAllTimersAsync();
+
+ expect(signals).toHaveLength(2);
+ expect(signals[0].aborted).toBe(true);
+ expect(signals[1].aborted).toBe(false);
+ });
+
+ it('discards a slow first response that lands after a faster second', async () => {
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async (_data, _root, ctx) => {
+ if (ctx?.query === 'slow') {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ return [{ value: 'STALE' }];
+ }
+ return [{ value: 'FRESH' }];
+ },
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.setQuery('slow');
+ loader.setQuery('fast');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ expect(loader.current().options).toEqual([{ value: 'FRESH' }]);
+ });
+
+ it('reports a rejection as an error state without throwing', async () => {
+ const boom = new Error('network down');
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ optionsMode: 'async',
+ options: () => Promise.reject(boom),
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({});
+ await vi.runAllTimersAsync();
+
+ expect(loader.current().status).toBe('error');
+ expect(loader.current().error).toBe(boom);
+ });
+
+ it('does not report an AbortError as an error state', async () => {
+ const abortErr = new Error('aborted');
+ abortErr.name = 'AbortError';
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ optionsMode: 'async',
+ options: () => Promise.reject(abortErr),
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({});
+ await vi.runAllTimersAsync();
+
+ expect(loader.current().status).not.toBe('error');
+ });
+
+ it('passes the query through to the loader', async () => {
+ const seen: (string | undefined)[] = [];
+ const field: FieldDescription = {
+ name: 'user',
+ type: 'text',
+ options: async (_data, _root, ctx) => {
+ seen.push(ctx?.query);
+ return OPTIONS;
+ },
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.setQuery('ada');
+ await vi.runAllTimersAsync();
+
+ expect(seen).toEqual(['ada']);
+ });
+
+ it('stops emitting after dispose', async () => {
+ const { states, onChange } = collect();
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async () => OPTIONS,
+ };
+ const loader = createOptionsLoader(field, onChange);
+
+ loader.update({});
+ loader.dispose();
+ await vi.runAllTimersAsync();
+
+ expect(states.map((s) => s.status)).toEqual(['loading']);
+ });
+});
+
+describe('an async loader that detection cannot see', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.useRealTimers();
+ __resetOptionsWarnings();
+ });
+
+ it('drops the promise instead of handing it to the renderer, and says why', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ // A spy wrapper loses `constructor.name === 'AsyncFunction'`, exactly as
+ // a memoiser or a transpiler helper would.
+ options: vi.fn(async () => OPTIONS) as never,
+ };
+
+ expect(resolveOptions(field, {})).toBeUndefined();
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0][0]).toContain("optionsMode: 'async'");
+ warn.mockRestore();
+ });
+
+ it('works normally once optionsMode is declared', async () => {
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ optionsMode: 'async',
+ options: vi.fn(async () => OPTIONS) as never,
+ };
+ const loader = createOptionsLoader(field, () => {});
+
+ loader.update({});
+ await vi.runAllTimersAsync();
+
+ expect(loader.current().options).toEqual(OPTIONS);
+ });
+});
+
+describe('loader state hygiene', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('clears a previous error when a retry starts loading', async () => {
+ let shouldFail = true;
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async () => {
+ if (shouldFail) {
+ throw new Error('network down');
+ }
+ return OPTIONS;
+ },
+ optionsDeps: (data) => [data.attempt],
+ };
+ const { states, onChange } = collect();
+ const loader = createOptionsLoader(field, onChange);
+
+ loader.update({ attempt: 1 });
+ await vi.runAllTimersAsync();
+ expect(loader.current().status).toBe('error');
+
+ shouldFail = false;
+ states.length = 0;
+ loader.update({ attempt: 2 });
+
+ // The very first state of the retry must not still carry the old error.
+ expect(states[0].status).toBe('loading');
+ expect(states[0].error).toBeUndefined();
+ });
+
+ it('does not treat a static array as async even when optionsMode says so', () => {
+ const field: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ optionsMode: 'async',
+ options: OPTIONS,
+ };
+
+ expect(isAsyncOptions(field)).toBe(false);
+ expect(resolveOptions(field, {})).toEqual(OPTIONS);
+ });
+});
diff --git a/packages/core/test/rendererProps.test.ts b/packages/core/test/rendererProps.test.ts
index b505a8b..a8c97e4 100644
--- a/packages/core/test/rendererProps.test.ts
+++ b/packages/core/test/rendererProps.test.ts
@@ -1,7 +1,9 @@
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
+ __resetReservedPropWarnings,
buildFieldRendererProps,
FIELD_RENDERER_PROP_KEYS,
+ makeErrorId,
makeFieldId,
} from '../src/rendererProps';
import type { FieldDescription } from '../src/types';
@@ -149,3 +151,179 @@ describe('buildFieldRendererProps', () => {
}
});
});
+
+describe('ariaDescribedBy', () => {
+ it('points at the error node id when the field has an error', () => {
+ const props = buildFieldRendererProps({
+ fieldDescription: {
+ name: 'title',
+ type: 'text',
+ required: true,
+ validate: () => 'Required',
+ },
+ data: { title: '' },
+ id: 'form-title',
+ });
+
+ expect(props.ariaInvalid).toBe(true);
+ expect(props.ariaDescribedBy).toBe('form-title-error');
+ expect(props.ariaDescribedBy).toBe(makeErrorId('form-title'));
+ });
+
+ it('is undefined when the field is valid', () => {
+ const props = buildFieldRendererProps({
+ fieldDescription: { name: 'title', type: 'text' },
+ data: { title: 'ok' },
+ id: 'form-title',
+ });
+
+ expect(props.ariaInvalid).toBe(false);
+ expect(props.ariaDescribedBy).toBeUndefined();
+ });
+
+ it('is undefined for a disabled field, which is never validated', () => {
+ const props = buildFieldRendererProps({
+ fieldDescription: {
+ name: 'title',
+ type: 'text',
+ disabled: true,
+ validate: () => 'Required',
+ },
+ data: { title: '' },
+ id: 'form-title',
+ });
+
+ expect(props.ariaDescribedBy).toBeUndefined();
+ });
+});
+
+describe('reserved props warning', () => {
+ beforeEach(() => {
+ __resetReservedPropWarnings();
+ });
+
+ it('warns when props carries a key the contract owns', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ buildFieldRendererProps({
+ fieldDescription: {
+ name: 'title',
+ type: 'text',
+ props: { placeholder: 'from props' },
+ },
+ data: {},
+ id: 'form-title',
+ });
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0][0]).toContain('placeholder');
+ expect(warn.mock.calls[0][0]).toContain('title');
+ warn.mockRestore();
+ });
+
+ it('warns only once for the same field and key', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const fieldDescription: FieldDescription = {
+ name: 'title',
+ type: 'text',
+ props: { placeholder: 'from props' },
+ };
+
+ buildFieldRendererProps({ fieldDescription, data: {}, id: 'a' });
+ buildFieldRendererProps({ fieldDescription, data: {}, id: 'a' });
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ warn.mockRestore();
+ });
+
+ it('stays silent for props keys the contract does not own', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ buildFieldRendererProps({
+ fieldDescription: {
+ name: 'title',
+ type: 'text',
+ props: { maxLength: 10, acceptFile: 'x' },
+ },
+ data: {},
+ id: 'form-title',
+ });
+
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ it('stays silent in production', () => {
+ const previous = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ buildFieldRendererProps({
+ fieldDescription: {
+ name: 'title',
+ type: 'text',
+ props: { placeholder: 'from props' },
+ },
+ data: {},
+ id: 'form-title',
+ });
+
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ process.env.NODE_ENV = previous;
+ });
+});
+
+describe('options loading state', () => {
+ const asyncField: FieldDescription = {
+ name: 'city',
+ type: 'text',
+ options: async () => [{ value: 'hn' }],
+ };
+
+ it('takes options and status from the supplied loader state', () => {
+ const props = buildFieldRendererProps({
+ fieldDescription: asyncField,
+ data: {},
+ id: 'f-city',
+ optionsState: { status: 'ready', options: [{ value: 'hn' }] },
+ });
+
+ expect(props.options).toEqual([{ value: 'hn' }]);
+ expect(props.optionsStatus).toBe('ready');
+ expect(props.optionsError).toBeUndefined();
+ });
+
+ it('surfaces a load failure', () => {
+ const boom = new Error('down');
+ const props = buildFieldRendererProps({
+ fieldDescription: asyncField,
+ data: {},
+ id: 'f-city',
+ optionsState: { status: 'error', error: boom },
+ });
+
+ expect(props.optionsStatus).toBe('error');
+ expect(props.optionsError).toBe(boom);
+ });
+
+ it('leaves a synchronous field untouched', () => {
+ const props = buildFieldRendererProps({
+ fieldDescription: {
+ name: 'city',
+ type: 'text',
+ options: [{ value: 'hn' }],
+ },
+ data: {},
+ id: 'f-city',
+ });
+
+ expect(props.options).toEqual([{ value: 'hn' }]);
+ expect(props.optionsStatus).toBeUndefined();
+ });
+
+ it('declares both new keys in the contract', () => {
+ expect(FIELD_RENDERER_PROP_KEYS).toContain('optionsStatus');
+ expect(FIELD_RENDERER_PROP_KEYS).toContain('optionsError');
+ });
+});
diff --git a/packages/core/test/validation.test.ts b/packages/core/test/validation.test.ts
index c6023a1..7e59418 100644
--- a/packages/core/test/validation.test.ts
+++ b/packages/core/test/validation.test.ts
@@ -1,6 +1,7 @@
-import { describe, expect, test } from 'vitest';
+import { describe, expect, it, test } from 'vitest';
import type { FieldDescription } from '../src';
import { zodValidator, yupValidator } from '../src/adapters';
+import { createMessageResolver } from '../src/messages';
import {
resolveDisabled,
resolveOptions,
@@ -9,6 +10,7 @@ import {
validateFields,
validateFieldsAsync,
} from '../src/validation';
+import { validators } from '../src/validators';
declare module '../src' {
interface FieldTypeMap {
@@ -241,3 +243,49 @@ describe('zodValidator and yupValidator', () => {
expect(validator('hello', {})).toBeUndefined();
});
});
+
+describe('validateFields threads the message context', () => {
+ const ctxFields: FieldDescription[] = [
+ { name: 'title', type: 'text', validate: validators.required() },
+ ];
+
+ it('reaches a built-in validator through validateFields', () => {
+ const result = validateFields(ctxFields, { title: '' }, undefined, {
+ t: createMessageResolver({ required: 'Bắt buộc' }),
+ });
+ expect(result.errors.title).toEqual(['Bắt buộc']);
+ });
+
+ it('reaches it through validateField too', () => {
+ expect(
+ validateField(ctxFields[0], '', { title: '' }, undefined, {
+ t: createMessageResolver({ required: 'Bắt buộc' }),
+ }),
+ ).toEqual(['Bắt buộc']);
+ });
+
+ it('keeps the English default with no context', () => {
+ expect(validateFields(ctxFields, { title: '' }).errors.title).toEqual([
+ 'Field is required',
+ ]);
+ });
+
+ it('descends into repeatable groups with the context intact', () => {
+ const grouped: FieldDescription[] = [
+ {
+ name: 'items',
+ type: 'text',
+ fields: [
+ { name: 'label', type: 'text', validate: validators.required() },
+ ],
+ },
+ ];
+ const result = validateFields(
+ grouped,
+ { items: [{ label: '' }] },
+ undefined,
+ { t: createMessageResolver({ required: 'Bắt buộc' }) },
+ );
+ expect(result.errors['items[0].label']).toEqual(['Bắt buộc']);
+ });
+});
diff --git a/packages/core/test/validators.test.ts b/packages/core/test/validators.test.ts
index 7122c17..354883c 100644
--- a/packages/core/test/validators.test.ts
+++ b/packages/core/test/validators.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, test } from 'vitest';
+import { describe, expect, it, test } from 'vitest';
+import { createMessageResolver } from '../src/messages';
import { validators } from '../src/validators';
describe('validators utility', () => {
@@ -56,3 +57,84 @@ describe('validators utility', () => {
expect(composed('abcde', {})).toBeUndefined();
});
});
+
+describe('validators read the message catalog', () => {
+ const ctx = {
+ t: createMessageResolver({
+ required: 'Bắt buộc',
+ minLength: 'Tối thiểu {min} ký tự',
+ max: 'Tối đa {max}',
+ }),
+ };
+
+ it('uses the catalog when no message is passed', () => {
+ expect(validators.required()('', {}, undefined, ctx)).toBe('Bắt buộc');
+ });
+
+ it('interpolates validator params into the catalog entry', () => {
+ expect(validators.minLength(8)('abc', {}, undefined, ctx)).toBe(
+ 'Tối thiểu 8 ký tự',
+ );
+ expect(validators.max(10)(11, {}, undefined, ctx)).toBe('Tối đa 10');
+ });
+
+ it('still lets an explicitly passed message win', () => {
+ expect(validators.required('Explicit')('', {}, undefined, ctx)).toBe(
+ 'Explicit',
+ );
+ });
+
+ it('keeps the English default when no catalog is in play', () => {
+ expect(validators.required()('')).toBe('Field is required');
+ expect(validators.minLength(8)('abc')).toBe('Minimum length is 8');
+ });
+
+ it('threads the context through compose', () => {
+ const composed = validators.compose(validators.required());
+ expect(composed('', {}, undefined, ctx)).toEqual(['Bắt buộc']);
+ });
+});
+
+describe('validators.matches', () => {
+ it('passes when the two values are equal', () => {
+ expect(
+ validators.matches('password')('secret', { password: 'secret' }),
+ ).toBeUndefined();
+ });
+
+ it('fails when they differ', () => {
+ expect(validators.matches('password')('typo', { password: 'secret' })).toBe(
+ 'Must match password',
+ );
+ });
+
+ it('takes its message from the catalog, with the other field interpolated', () => {
+ const ctx = { t: createMessageResolver({ matches: 'Phải khớp {other}' }) };
+ expect(
+ validators.matches('password')(
+ 'typo',
+ { password: 'secret' },
+ undefined,
+ ctx,
+ ),
+ ).toBe('Phải khớp password');
+ });
+
+ it('lets an explicit message win', () => {
+ expect(
+ validators.matches('password', 'Passwords differ')('typo', {
+ password: 'secret',
+ }),
+ ).toBe('Passwords differ');
+ });
+
+ it('skips an empty value, leaving required to report it', () => {
+ expect(
+ validators.matches('password')('', { password: 'secret' }),
+ ).toBeUndefined();
+ });
+
+ it('compares with Object.is so two NaNs match', () => {
+ expect(validators.matches('a')(NaN, { a: NaN })).toBeUndefined();
+ });
+});
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index a05a494..09796bb 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -1,5 +1,63 @@
# @dynamic-field-kit/react
+## 1.7.0
+
+### Minor Changes
+
+- Dirty-baseline rebasing, accessible validation errors, form-level message catalogs, and async field options across React, Vue, and Angular.
+- 9b06e3f: Validation messages can be set once per form via `useDynamicForm({ messages })`,
+ or process-wide via `setDefaultMessages`, instead of passing a string to every
+ validator on every field. Built-in validators now resolve their message when
+ they run rather than when the field description is built, which is what made a
+ catalog impossible before. A message passed directly to a validator still wins,
+ and the English defaults are unchanged when no catalog is supplied.
+
+ `ValidationContext` - already `validate`'s fourth argument - gains an optional
+ `t` resolver, so a hand-written validator can translate its own messages too.
+
+ Adds `validators.matches(otherFieldName)` for confirm-password and
+ confirm-email fields, which every consumer was hand-writing.
+
+ No locale bundles ship: the mechanism is here, the translations are yours.
+
+- a7358f9: Fix per-field `dirty`, which was measured against a baseline captured at mount
+ and never re-based - wrong after `reset(newValues)` on all three adapters, and
+ wrong on React and Vue for values that arrive after mount, where every field
+ reported dirty forever.
+
+ Adds `baselineValues` and `getDirtyValues()` to the form store on all three
+ adapters, and an `initialProperties` prop to `MultiFieldInput` for re-basing
+ without a store. Comparison moves from `!==` to `Object.is`, so a `NaN` numeric
+ field no longer reads as permanently dirty.
+
+ React's `useDynamicForm` no longer validates the same data twice per change.
+
+- 53ed45a: `options` can now return a promise, covering both dependent selects
+ (`optionsDeps`) and search-remote pickers (`onOptionsQuery`). Renderers receive
+ `optionsStatus` and `optionsError` alongside `options`.
+
+ `debounceMs` was declared on `FieldDescription`, published in the `.d.ts` and
+ read by no implementation anywhere - setting it did nothing. It now debounces
+ these loads.
+
+ Debounce, abort of a superseded request, and discarding a response that lands
+ out of order all live in core's `createOptionsLoader`, so the three adapters
+ share one implementation. Synchronous and static options are untouched and never
+ enter a loading state.
+
+- e35e876: `ariaDescribedBy` is now `${id}-error` when a field has an error instead of
+ being hard-coded `undefined`, and `makeErrorId` is exported so a custom renderer
+ can put the matching id on its message element. Without this,
+ `focusFirstInvalidField` had nothing to find for anyone following the official
+ renderer recipe.
+
+ Default renderers now render the validation message they were already being
+ handed - the one visible change in this release. Custom renderers are untouched,
+ so nobody gets two copies of their own message.
+
+ Development builds now warn when `FieldDescription.props` carries a key the
+ renderer prop contract owns, which 1.6.0 made possible to lose silently.
+
## 1.6.0
### Minor Changes
diff --git a/packages/react/README.md b/packages/react/README.md
index f9e08cc..1171802 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -43,6 +43,11 @@ both packages:
- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult`
- `collectFieldPaths` — the leaf paths a schema actually has in the data (`contacts[0].email`)
- `indexGroupPathMap` — index an error or touched map by repeatable-group item
+- `makeErrorId` — the id a renderer puts on its message element so
+ `aria-describedby` resolves
+- `createOptionsLoader` / `isAsyncOptions` — the async options engine
+- `createMessageResolver` / `setDefaultMessages` / `MessageCatalog` — validation
+ message catalog
- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options
- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …)
- `ValidationResult` / `ValidationContext`
@@ -140,6 +145,7 @@ const form = useDynamicForm({
initialValues: { country: 'VN' },
validateOnBlur: true, // default
validateOnChange: false, // default
+ messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions
});