Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/brown-buttons-shake.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,18 @@ resolves all of it through core. Keeping a second copy of that logic beside the
shared one is how the adapters drifted apart to begin with; the equivalents are
`resolveOptions`, `resolveDisabled`, `resolveReadOnly` and `validateField`,
already re-exported from this package.

Form validity now reflects current data immediately instead of merely checking
the lazily populated `errors` map. The error map remains lazy for display, and
passing a form binding (or the new controlled `errors` input) makes that same
map the renderer's source of truth, removing the previous timing mismatch.

Promise-based validators are no longer silently accepted on submit.
`validateFields` reports unresolved field names in `pending`; every framework
form helper uses one async-capable validation pass before dispatching submit
callbacks. React, Vue and Angular also expose
`validateAsync()` for explicit pre-submit checks. Live `isValid` remains a
synchronous answer because a property/computed/signal cannot await.

The new UI-kit recipes show complete touched/error wiring for Ant Design,
Vuetify and Angular Material.
183 changes: 183 additions & 0 deletions docs/ui-kit-recipes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# UI kit recipes

These recipes keep `dynamic-field-kit` in charge of values, touched state and
validation while a UI kit owns presentation. The rule is the same everywhere:
display `error` only when `touched`, forward `id`, and call the supplied value
and blur callbacks.

## React + Ant Design

```tsx
import { Form, Input } from 'antd';
import {
fieldRegistry,
type FieldRendererProps,
} from '@dynamic-field-kit/react';

function AntText(props: FieldRendererProps<string>) {
const message = props.touched
? [props.error].flat().filter(Boolean)[0]
: undefined;

return (
<Form.Item
label={props.label}
required={props.required}
validateStatus={message ? 'error' : undefined}
help={message}
>
<Input
id={props.id}
value={props.value ?? ''}
placeholder={props.placeholder}
disabled={props.disabled}
readOnly={props.readOnly}
status={message ? 'error' : undefined}
onChange={(event) => props.onValueChange?.(event.target.value)}
onBlur={props.onBlur}
/>
</Form.Item>
);
}

fieldRegistry.register('text', AntText);
fieldRegistry.register('email', AntText);
fieldRegistry.register('password', AntText);
```

```tsx
const form = useDynamicForm({ fields, initialValues });

<form onSubmit={form.handleSubmit(save)}>
<MultiFieldInput fieldDescriptions={fields} form={form} />
<button disabled={!form.isValid || form.isSubmitting}>Save</button>
</form>;
```

API references: [Ant Design Input](https://ant.design/components/input/) and
[Form](https://ant.design/components/form/).

## Vue + Vuetify

```ts
import { defineComponent, h, type PropType } from 'vue';
import { VTextField } from 'vuetify/components';
import { fieldRegistry } from '@dynamic-field-kit/vue';

const VuetifyText = defineComponent({
props: {
id: String,
value: String,
label: String,
placeholder: String,
disabled: Boolean,
readOnly: Boolean,
touched: Boolean,
error: [String, Array] as PropType<string | string[]>,
onValueChange: Function as PropType<(value: string) => void>,
onBlur: Function as PropType<() => void>,
},
setup(props) {
return () =>
h(VTextField, {
id: props.id,
modelValue: props.value ?? '',
label: props.label,
placeholder: props.placeholder,
disabled: props.disabled,
readonly: props.readOnly,
errorMessages: props.touched ? props.error : undefined,
'onUpdate:modelValue': props.onValueChange,
onBlur: props.onBlur,
});
},
});

fieldRegistry.register('text', VuetifyText);
fieldRegistry.register('email', VuetifyText);
fieldRegistry.register('password', VuetifyText);
```

```vue
<script setup lang="ts">
const submit = form.handleSubmit(save);
</script>

<form @submit="submit">
<MultiFieldInput :field-descriptions="fields" :form="form" />
<v-btn type="submit" :disabled="!form.isValid.value" :loading="form.isSubmitting.value">
Save
</v-btn>
</form>
```

API reference: [Vuetify text fields](https://vuetifyjs.com/en/components/text-fields/).

## Angular + Angular Material

```ts
import { Component } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { BaseInputComponent, fieldRegistry } from '@dynamic-field-kit/angular';

@Component({
selector: 'app-material-text',
standalone: true,
imports: [MatFormFieldModule, MatInputModule],
template: `
<mat-form-field appearance="outline">
<mat-label>{{ label }}</mat-label>
<input
matInput
[id]="id"
[value]="value ?? ''"
[placeholder]="placeholder ?? ''"
[required]="required ?? false"
[disabled]="disabled ?? false"
[readOnly]="readOnly ?? false"
[errorStateMatcher]="errorStateMatcher"
(input)="valueChange.emit($any($event.target).value)"
/>
@if (touched && error) {
<mat-error>{{ errorText }}</mat-error>
}
</mat-form-field>
`,
})
export class MaterialTextRenderer extends BaseInputComponent {
readonly errorStateMatcher: ErrorStateMatcher = {
isErrorState: () => Boolean(this.touched && this.error),
};

get errorText(): string {
return ([] as string[]).concat(this.error ?? [])[0] ?? '';
}
}

fieldRegistry.register('text', MaterialTextRenderer as never);
```

Bind both metadata maps so the store is the single source of truth:

```html
<dfk-multi-field-input
[fieldDescriptions]="fields"
[properties]="store.data()"
[touched]="store.touched()"
[errors]="store.errors()"
(onChange)="store.handleChange($event)"
(onBlurField)="store.handleBlur($event)"
/>
```

API references: [Angular Material form field](https://material.angular.dev/components/form-field/overview)
and [input](https://material.angular.dev/components/input/overview).

## Async validation

All three form helpers expose `validateAsync()`. Their `handleSubmit()` methods
run one async-capable validation pass before calling `onValid`. Live `isValid`
reflects synchronous rules; call
`validateAsync()` when UI must check an async rule before submit.
19 changes: 12 additions & 7 deletions packages/angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ both packages:
renderer contracts every adapter shares
- `ValidationResult`

`createDynamicFormStore` validates **synchronously** via `validateFields`,
including on submit. Fields whose `validate` hook returns a Promise are treated
as valid on that path, so run async rules through `validateFieldsAsync`
yourself. See the
`createDynamicFormStore` keeps live validation synchronous. Its `handleSubmit`
runs one async-capable validation pass; call the exposed `validateAsync()` when
you need that result before submit. See the
[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation)
for the full rules.

For a complete UI integration, see the
[Angular Material recipe](../../docs/ui-kit-recipes.md#angular--angular-material).

## Basic setup (Angular 19+)

1. Import the component and register fields before bootstrap.
Expand Down Expand Up @@ -144,6 +146,7 @@ import {
[fieldDescriptions]="fields"
[properties]="store.data()"
[touched]="store.touched()"
[errors]="store.errors()"
(onChange)="store.handleChange($event)"
(onBlurField)="store.handleBlur($event)"
></dfk-multi-field-input>
Expand All @@ -168,7 +171,7 @@ export class MyForm {
| ----------------------------------- | --------------------------------------------------------------------------------- |
| `data()` | Current form data, with `computeValue` fields applied |
| `errors()` | `Record<string, string[]>`, keyed like `validateFields` |
| `isValid()` / `isDirty()` | No errors recorded / any value has changed |
| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed |
| `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted |
| `touched()` | Fields that have been blurred |
| `handleChange(data)` | Replace the whole form data — bind to `(onChange)` |
Expand All @@ -178,15 +181,17 @@ export class MyForm {
| `touchAll()` | Mark every field touched — `handleSubmit` already calls it |
| `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
| `validate()` | Validate now, returns a boolean |
| `validateAsync()` | Validate now, awaiting Promise-based rules |
| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
| `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches |

`MultiFieldInput` emits `(onBlurField)` with the field's name, driven by a
`focusout` listener — so it works with any renderer, without the renderer
needing a blur output of its own.

Binding `[touched]="store.touched()"` makes the store the single source of
truth, and is what makes an invalid submit visible: `handleSubmit` marks every
Binding `[touched]="store.touched()"` and `[errors]="store.errors()"` makes the
store the single source of truth for renderer metadata. Touched state is what
makes an invalid submit visible: `handleSubmit` marks every
field touched before validating, so a renderer that gates its error on the
`touched` input shows it even for fields the user never focused. `reset()`
clears touched the same way. Leave `[touched]` unbound and `MultiFieldInput`
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/components/FieldInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export class FieldInput implements OnChanges {
@Input() disabled?: boolean;
@Input() readOnly?: boolean;
@Input() error?: string | string[];
/** Distinguishes a controlled empty error from an omitted error input. */
@Input() validationControlled = false;
/** Whether the field has been blurred, or marked touched by a form store. */
@Input() touched?: boolean;
/** Whether the value differs from the one the form opened with. */
Expand Down Expand Up @@ -122,7 +124,9 @@ export class FieldInput implements OnChanges {

// Explicitly bound inputs override what the field description resolves to,
// so a host can still drive options/disabled/error itself.
const error = this.error ?? base.error;
const error = this.validationControlled
? this.error
: (this.error ?? base.error);
return {
...base,
options: this.options ?? base.options,
Expand Down
24 changes: 24 additions & 0 deletions packages/angular/src/components/MultiFieldInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ function nextInstanceId(): number {
[idPrefix]="effectiveIdPrefix"
[touched]="isTouched(field.name)"
[dirty]="isFieldDirty(field.name)"
[error]="fieldErrors(field.name)"
[validationControlled]="errors !== undefined"
(onValueChangeField)="onFieldChange($event)"
(onBlurField)="handleBlurField($event)"
></dfk-field-input>
Expand All @@ -86,6 +88,7 @@ function nextInstanceId(): number {
[fieldDescriptions]="field.fields"
[properties]="item"
[rootData]="rootData ?? data"
[errors]="errorsForItem(field.name, i)"
(onChange)="onGroupItemChange(field, i, $event)"
></dfk-multi-field-input>
</div>
Expand Down Expand Up @@ -139,6 +142,8 @@ export class MultiFieldInput implements OnInit, OnChanges {
* internal, blur-only tracker.
*/
@Input() touched?: Record<string, boolean>;
/** Controlled error map; bind the form store's `errors()` signal here. */
@Input() errors?: Record<string, string[]>;
/** Emits the next touched map whenever a field is blurred. */
@Output() touchedChange = new EventEmitter<Record<string, boolean>>();
/**
Expand Down Expand Up @@ -185,6 +190,25 @@ export class MultiFieldInput implements OnInit, OnChanges {
return this.data[fieldName] !== this.initialProperties[fieldName];
}

fieldErrors(fieldName: string): string[] | undefined {
return this.errors?.[fieldName];
}

errorsForItem(
fieldName: string,
index: number,
): Record<string, string[]> | undefined {
if (this.errors === undefined) {
return undefined;
}
const prefix = `${fieldName}[${index}].`;
return Object.fromEntries(
Object.entries(this.errors)
.filter(([key]) => key.startsWith(prefix))
.map(([key, messages]) => [key.slice(prefix.length), messages]),
);
}

/**
* Clears the internally tracked touched state. Only meaningful in
* uncontrolled mode - when `touched` is bound, resetting the form store
Expand Down
14 changes: 12 additions & 2 deletions packages/angular/src/lib/dynamic-form.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FieldDescription,
Properties,
validateFields,
validateFieldsAsync,
} from '@dynamic-field-kit/core';

export interface DynamicFormOptions {
Expand All @@ -26,14 +27,22 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
const isSubmitting = signal<boolean>(false);
const isSubmitted = signal<boolean>(false);

const isValid = computed(() => Object.keys(errors()).length === 0);
// Errors remain lazy for display, while validity always reflects current
// data. Promise-based rules are provisional until validateAsync/submit.
const isValid = computed(() => validateFields(fields, data()).valid);

function validate(): boolean {
const res = validateFields(fields, data());
errors.set(res.errors);
return res.valid;
}

async function validateAsync(): Promise<boolean> {
const res = await validateFieldsAsync(fields, data());
errors.set(res.errors);
return res.valid;
}

function handleChange(newData: Properties) {
const next = applyComputedValues(fields, newData);
data.set(next);
Expand Down Expand Up @@ -106,7 +115,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
// show its error. Without this, submitting an untouched form appears
// to do nothing at all.
touchAll();
const res = validateFields(fields, data());
const res = await validateFieldsAsync(fields, data());
errors.set(res.errors);
isSubmitted.set(true);
if (res.valid) {
Expand Down Expand Up @@ -136,6 +145,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) {
handleBlur,
reset,
validate,
validateAsync,
handleSubmit,
};
}
Loading