diff --git a/.changeset/brave-melons-shave.md b/.changeset/brave-melons-shave.md new file mode 100644 index 0000000..d0c2070 --- /dev/null +++ b/.changeset/brave-melons-shave.md @@ -0,0 +1,21 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +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. diff --git a/.changeset/great-pugs-repeat.md b/.changeset/great-pugs-repeat.md new file mode 100644 index 0000000..37cee48 --- /dev/null +++ b/.changeset/great-pugs-repeat.md @@ -0,0 +1,18 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +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. diff --git a/.changeset/olive-hounds-look.md b/.changeset/olive-hounds-look.md new file mode 100644 index 0000000..707f903 --- /dev/null +++ b/.changeset/olive-hounds-look.md @@ -0,0 +1,19 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +`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. diff --git a/.changeset/olive-pumas-argue.md b/.changeset/olive-pumas-argue.md new file mode 100644 index 0000000..8c98386 --- /dev/null +++ b/.changeset/olive-pumas-argue.md @@ -0,0 +1,19 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +`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. diff --git a/README.md b/README.md index eaf96c1..c881305 100644 --- a/README.md +++ b/README.md @@ -308,14 +308,14 @@ const fields: FieldDescription[] = [ ]; ``` -| Property | Description | -| ----------------- | --------------------------------------------------------------------------------------------------- | -| validate | `(value, data, rootData?, context?) => string | string[] | undefined | Promise<...>`. Falsy means valid. `context.signal` aborts when a newer run supersedes this one. | -| validationMode | `'sync' | 'async'`. Declares a validator that returns a Promise without the `async` keyword, so the live pass skips it instead of calling it. | -| validators | Built-in helpers: `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `compose` | -| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` | -| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. | -| readOnlyCondition | `(data, rootData?) => boolean`. | +| Property | Description | +| ----------------- | -------------------------------------------------------------------------------------------------------------- | +| validate | `(value, data, rootData?, context?) => string | string[] | undefined | Promise<...>`. Falsy means valid. `context.signal` aborts when a newer run supersedes this one. | +| validationMode | `'sync' | 'async'`. Declares a validator that returns a Promise without the `async` keyword, so the live pass skips it instead of calling it. | +| validators | Built-in helpers: `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `matches`, `compose` | +| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` | +| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. | +| readOnlyCondition | `(data, rootData?) => boolean`. | `MultiFieldInput` passes each field's current `error` and effective `disabled`/`readOnly` to its renderer (via `FieldRendererProps`), and emits an @@ -722,3 +722,104 @@ MIT © [vannt-dev](https://github.com/vannt-dev) ## 🤝 Contributing Contributions welcome! Please see individual package READMEs for setup and development instructions. + +### Validation messages + +Set the built-in validators' messages once for a whole form instead of passing a +string to every validator on every field: + +```ts +const form = useDynamicForm({ + fields, + messages: { + required: 'Bắt buộc', + minLength: 'Tối thiểu {min} ký tự', + matches: 'Phải khớp {other}', + }, +}); +``` + +A message passed directly to a validator still wins, and any key you omit falls +back to the English default. For code that calls `validateFields` directly and +has no form to hang a catalog on, `setDefaultMessages(catalog)` sets a +process-wide one; a per-form catalog takes precedence over it. + +| 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 library.** Supply your own catalog — the +mechanism is here, the translations are yours. + +A placeholder with no matching param is left in the string verbatim rather than +replaced with `undefined`, so a typo shows up as a visible `{unit}` instead of +a mystery. + +### Async options + +`options` can return a promise. Two shapes are covered, and the difference is +what triggers a reload. + +**Dependent options** — the reload is driven by form data: + +```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, +} +``` + +**Search-remote** — the reload is driven by the renderer's own search box, +which the form data never sees. The renderer calls `onOptionsQuery`: + +```ts +{ + name: 'assignee', + type: 'userPicker', + options: async (_data, _rootData, ctx) => + fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal }) + .then((r) => r.json()), + debounceMs: 300, +} +``` + +The renderer receives `optionsStatus` (`'idle' | 'loading' | 'ready' | 'error'`), +`optionsError`, and `onOptionsQuery`. + +| Field property | Effect | +| -------------- | ------------------------------------------------------------------------------ | +| `optionsDeps` | Values a reload depends on, compared shallowly. Defaults to `[]` — fetch once. | +| `optionsMode` | `'async'` for a loader that returns a promise without the `async` keyword. | +| `debounceMs` | Collapses rapid reloads into one fetch. Applies to async options only. | + +Superseded requests are aborted through `ctx.signal`, and a slow response that +lands after a newer one is discarded, so the option list always reflects the +most recent request rather than the last one to arrive. + +Native `async` functions are detected automatically. A loader wrapped in a +memoiser, a spy or a transpiler helper is **not** — `constructor.name` is no +longer `'AsyncFunction'`. Declare `optionsMode: 'async'` for those; without it +the promise is dropped and a development warning says so. + +Note that a form whose `properties` arrive after mount sees its data change +twice (empty, then loaded), which is two loads without a `debounceMs`. Setting +one collapses them. + +The loader is bound to the field description it first saw. Changing a field's +`name` swaps it — every adapter keys each field by name, so that remounts — +but changing only the `options` closure on a same-named field does not. That is +deliberate: a `fields` array built inline in a component body gets a fresh +closure on every render, and rebuilding on closure identity would refetch in a +loop. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md new file mode 100644 index 0000000..f0dabb9 --- /dev/null +++ b/docs/MIGRATING.md @@ -0,0 +1,193 @@ +# Migrating + +Version-by-version tables of what changed and what to do about it. For the full +list of changes see each package's `CHANGELOG.md`. + +## 1.6.x → 1.7.0 + +Everything here is additive or a bugfix. No API was removed, and no peer range +moved. + +### Per-field `dirty` is now correct after a reset, and after a late load + +`dirty` was measured against a baseline captured once at mount and never +reassigned. Two cases were wrong: + +- after `reset(newValues)` every field compared against the **pre-reset** + values, so fields the reset had just changed read as clean and fields it left + alone read as dirty; +- on React and Vue, when `properties` arrived from a fetch **after** mount (the + normal shape of an edit form) the baseline was `{}`, so every field reported + `dirty: true` forever. + +The baseline now comes from whoever owns the values. + +| Before | After | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `` — baseline frozen at mount, `{}` if `data` was not ready yet | unchanged call; the baseline is the first non-`undefined` `properties` | +| no way to re-base the baseline | `` | +| `form={form}` — baseline frozen at mount | unchanged call; the baseline follows `form.reset(newValues)` | + +**No change is required.** If you worked around this by remounting the form with +a changing `key`, you can drop the workaround. + +**One case the automatic baseline cannot cover.** It falls back to the first +`properties` that is not `undefined`, and `{}` is a real value - a form that +genuinely opens blank is indistinguishable from one still waiting on a fetch. +So `properties={data}` where `data` starts as `{}` locks the baseline to `{}` +and every field reads dirty once the values land. Pass `initialProperties` +explicitly, start from `undefined` rather than `{}`, or drive the form through a +store and use `reset(fetched)`. + +Angular is the exception: it never had the late-load bug, because its `init()` +only records a baseline once `properties` is actually set. It did have the reset +bug. Since the Angular adapter has no `form` shorthand, pass the store's +baseline in explicitly: + +```html + +``` + +### `getDirtyValues()` + +For PATCH-style submits that should carry only what the user actually edited. + +| Before | After | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `const changed = Object.fromEntries(Object.entries(form.data).filter(([k, v]) => v !== original[k]))` | `const changed = form.getDirtyValues()` | + +Keys present in the data but absent from the baseline count as dirty. Comparison +is by `Object.is`, so a `NaN` numeric field does not read as permanently dirty. + +### `baselineValues` + +The values `dirty` is measured against, now exposed on the form store. React +returns a plain object, Vue a `Ref`, Angular a `Signal`. + +This is **not** the same as the `initialValues` option: that option never +changes, while the baseline is replaced by `reset(newValues)`. They differ as +soon as a form is reset. + +### React only: one fewer validation pass per keystroke + +`useDynamicForm` validated the same data twice on every change — once eagerly in +`handleChange`, once again in an effect after commit. It now validates once. + +Synchronous validators are pure by contract, so this is invisible unless you +were counting calls in a test or relying on a side effect inside a validator. +Vue and Angular were never affected; their stores run no watch or effect and +already validated exactly once. + +### Default renderers now show validation messages + +**This is the one visible change in 1.7.0.** If a field uses the built-in +renderers — that is, you registered no renderer for its type — an invalid field +now renders + +```html + +``` + +as a sibling of the control. Previously the default renderers were handed +`error` and dropped it, so the form showed nothing at all. + +Custom renderers are **not** affected. The node is emitted only where a default +renderer was used, so nobody who renders their own message gets a second copy. + +The node ships with no styling beyond that class hook. To keep the old silence: + +```css +.dfk-field-error { + display: none; +} +``` + +### `ariaDescribedBy` now has a value + +It was hard-coded `undefined`. It is now `` `${id}-error` `` when the field has +an error, and `undefined` while it is valid. + +If your renderer forwards `aria-describedby`, put the matching id on your +message element. `makeErrorId(id)` is exported from core and re-exported by all +three adapters: + +| Before | After | +| ------------------------------------- | ----------------------------------------------------------- | +| `help={message}` | `help={{message}}` | +| `aria-describedby` always `undefined` | bind `props.ariaDescribedBy` directly — no need to clear it | + +This matters beyond screen readers: `focusFirstInvalidField` selects +`[aria-invalid="true"]`, so a renderer that never forwards `ariaInvalid` makes +that helper silently do nothing. See +[Forward the aria props](./ui-kit-recipes.md#forward-the-aria-props). + +### Dev-mode warning when `props` shadows the contract + +1.6.0 moved `placeholder`, `min`, `max`, `step`, `accept` and `multiple` to the +top level of `FieldDescription`. Values left behind in `props` were discarded +silently — no throw, no warning, the value just vanished. + +A development-only `console.warn` now names the field and the key, once per +pair. Production builds are unchanged and emit nothing. + +### Validation messages can be set per form + +Previously the only way to change a built-in validator's message was to pass a +string on every field of every form. The validator baked that string in when the +field description was built, so nothing set later could reach it. + +| Before | After | +| ------------------------------------------------------------ | ---------------------------------------------------------------- | +| `validators.required('Bắt buộc')` on every field | `useDynamicForm({ fields, messages: { required: 'Bắt buộc' } })` | +| no way to change messages for a direct `validateFields` call | `setDefaultMessages(catalog)` | + +Fully backward compatible: a message passed to a validator still wins over any +catalog, and with no catalog the English defaults are unchanged. + +`ValidationContext` — already the fourth argument to `validate`, carrying +`signal` — gains an optional `t`. A hand-written validator can use it to +translate its own messages. Nothing is required of existing validators. + +### `validators.matches` + +| Before | After | +| --------------------------------------------------------------------------------- | ------------------------------------------ | +| `validate: (value, data) => (value !== data.password ? 'Must match' : undefined)` | `validate: validators.matches('password')` | + +Skips empty values so `required` owns that case rather than both firing at once, +and compares with `Object.is` so two `NaN`s match. + +### `debounceMs` finally does something + +Before 1.7.0 `debounceMs` was declared on `FieldDescription` and published in +the `.d.ts`, but no implementation read it anywhere. Setting it did nothing at +all. + +It now debounces async options loading. **Nobody's behaviour changes**, because +there was no behaviour — but if you set it hoping it would debounce +_validation_, it still does not. Async validation is debounced by not running on +the live pass at all; see `validationMode`. + +### Async options + +`options` may now return a promise, and the renderer gets `optionsStatus`, +`optionsError` and `onOptionsQuery` alongside it. + +| Before | After | +| --------------------------------------------------------------- | ---------------------------------------- | +| renderer manages its own `loading`/`options` state and debounce | `options: async (data, _root, ctx) => …` | +| no way to express "reload when country changes" | `optionsDeps: (data) => [data.country]` | +| search box wired by hand inside the renderer | `onOptionsQuery(query)` | + +Fully additive. A static array or a synchronous `(data, rootData) => Options[]` +behaves exactly as before and never enters a loading state. + +One detail worth knowing: `options` keeps a **single** signature, +`(data, rootData?, ctx?)`, rather than becoming a union of a sync and an async +shape. A union of two function types defeats TypeScript's contextual inference, +which would have made every existing `options: (data) => …` an implicit-`any` +error. Returning a promise is what makes a loader async. diff --git a/docs/ui-kit-recipes.md b/docs/ui-kit-recipes.md index d7da105..15f9ddc 100644 --- a/docs/ui-kit-recipes.md +++ b/docs/ui-kit-recipes.md @@ -2,8 +2,12 @@ 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. +display `error` only when `touched`, forward `id` **and the three aria props**, +put `makeErrorId(id)` on the element showing the message, and call the supplied +value and blur callbacks. + +See [Forward the aria props](#forward-the-aria-props) for why the aria half is +not optional polish. ## React + Ant Design @@ -11,6 +15,7 @@ and blur callbacks. import { Form, Input } from 'antd'; import { fieldRegistry, + makeErrorId, type FieldRendererProps, } from '@dynamic-field-kit/react'; @@ -24,7 +29,11 @@ function AntText(props: FieldRendererProps) { label={props.label} required={props.required} validateStatus={message ? 'error' : undefined} - help={message} + // The id is what `ariaDescribedBy` points at. Without it the reference + // dangles and a screen reader has nothing to read out. + help={ + message ? {message} : undefined + } > ) { disabled={props.disabled} readOnly={props.readOnly} status={message ? 'error' : undefined} + aria-invalid={props.ariaInvalid} + aria-required={props.ariaRequired} + aria-describedby={props.ariaDescribedBy} onChange={(event) => props.onValueChange?.(event.target.value)} onBlur={props.onBlur} /> @@ -208,3 +220,88 @@ For the UI, bind the three status members rather than `isValid` alone - Typing cancels a live run in flight, so a stale result never overwrites a newer one. A submit is not cancelled by typing: it validates the snapshot it was given and always calls `onValid` or `onInvalid`. + +## Forward the aria props + +`buildFieldRendererProps` computes `ariaInvalid`, `ariaRequired` and +`ariaDescribedBy` for every field, on every adapter. A custom renderer that +drops them throws that work away. + +This is not optional polish. `focusFirstInvalidField` — the helper for moving +focus to the first problem after a failed submit — selects + +```js +'[aria-invalid="true"], input:invalid, select:invalid, textarea:invalid'; +``` + +A renderer that never sets `aria-invalid` makes that helper **silently do +nothing**. It does not throw and it does not warn; the form simply fails to +submit and focus stays where it was. This was the single most common gap found +in real consumer renderers, in part because earlier versions of this page did +not mention the aria props at all. + +Three things to wire, in every renderer: + +| Prop | Where it goes | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | +| `ariaInvalid` | `aria-invalid` on the focusable control | +| `ariaRequired` | `aria-required` on the focusable control | +| `ariaDescribedBy` | `aria-describedby` on the control, **and** `makeErrorId(id)` as the `id` of the element rendering the message | + +`ariaDescribedBy` is `undefined` while the field is valid and +`` `${id}-error` `` once it has an error, so binding it unconditionally is +correct — there is nothing to clear. + +If you use the built-in renderers you get all of this already; they render the +message node themselves. + +## Async options in a renderer + +A field with an async loader hands the renderer three extra props. A minimal +search-remote picker uses all three: + +```tsx +import { type FieldRendererProps } from '@dynamic-field-kit/react'; + +function UserPicker({ + id, + options, + optionsStatus, + optionsError, + onOptionsQuery, + value, + onValueChange, + onBlur, +}: FieldRendererProps) { + return ( +
+ onOptionsQuery?.(e.target.value)} + onBlur={onBlur} + /> + {optionsStatus === 'loading' && } + {optionsStatus === 'error' && ( + {String((optionsError as Error)?.message)} + )} +
    + {(options ?? []).map((o) => ( +
  • + +
  • + ))} +
+
+ ); +} +``` + +`optionsStatus` is `undefined` for a field with static or synchronous options, +so `optionsStatus === 'loading'` is safely `false` there — one renderer works +for both. + +Do not debounce inside the renderer. `debounceMs` on the field already collapses +rapid `onOptionsQuery` calls, and a second layer would only add latency. diff --git a/packages/angular/README.md b/packages/angular/README.md index 807c3eb..d8b140c 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -41,7 +41,7 @@ npm install @dynamic-field-kit/core@^1.5.0 @dynamic-field-kit/angular@^1.5.0 `dirty`, `id` and the aria flags used to be missing here, which left an Angular renderer no way to tell whether a field had been touched - `DynamicFormOptions` — what `createDynamicFormStore` takes: `fields`, - `initialValues`, `validateOnBlur`, `validateOnChange` + `initialValues`, `validateOnBlur`, `validateOnChange`, `messages` - `LayoutConfig` / `ColumnLayoutConfig` / `RowLayoutConfig` / `GridLayoutConfig` — the layout config types, re-exported from core - `BaseLayoutConfig` / `ResponsiveLayoutConfig` — this adapter's historical @@ -59,7 +59,15 @@ both packages: - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) - `FieldDescription` / `FieldTypeKey` / `FieldRendererProps` — the schema and renderer contracts every adapter shares -- `ValidationResult` / `ValidationContext` +- `ValidationResult` / `ValidationContext` — the context carries `signal` and the + optional `t` message resolver +- `buildFieldRendererProps` / `makeFieldId` / `makeErrorId` / + `FIELD_RENDERER_PROP_KEYS` — the renderer prop contract. `makeErrorId(id)` is + what a custom renderer puts on its message element so `aria-describedby` + resolves +- `createOptionsLoader` / `isAsyncOptions` — the async options engine +- `createMessageResolver` / `setDefaultMessages` / `MessageCatalog` — validation + message catalog `createDynamicFormStore` keeps live validation synchronous - a validator declared or detected as async is never invoked on that path. Its `handleSubmit` runs one @@ -170,6 +178,7 @@ import { [errors]="store.errors()" (onChange)="store.handleChange($event)" (onBlurField)="store.handleBlur($event)" + [initialProperties]="store.baselineValues()" >
@@ -181,6 +190,7 @@ export class MyForm { fields, initialValues: { country: 'VN' }, validateOnBlur: true, // default + messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions }); // handleSubmit returns a handler, exactly like React and Vue. @@ -188,26 +198,28 @@ export class MyForm { } ``` -| Member | Description | -| ----------------------------------- | --------------------------------------------------------------------------------- | -| `data()` | Current form data, with `computeValue` fields applied | -| `errors()` | `Record`, keyed like `validateFields` | -| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed | -| `isValidating()` | An async validation pass is in flight | -| `isValidationComplete()` | Every applicable validator finished and none is in flight | -| `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" | -| `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)` | -| `setFieldValue(name, value)` | Change one field | -| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` | -| `setFieldTouched(name, value?)` | Set touched explicitly | -| `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 | +| Member | Description | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `data()` | Current form data, with `computeValue` fields applied | +| `errors()` | `Record`, keyed like `validateFields` | +| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed | +| `baselineValues()` | Signal holding the values `dirty` is measured against - `initialValues` until `reset(newValues)` replaces them | +| `getDirtyValues()` | Only the entries differing from `baselineValues`, for PATCH-style submits | +| `isValidating()` | An async validation pass is in flight | +| `isValidationComplete()` | Every applicable validator finished and none is in flight | +| `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" | +| `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)` | +| `setFieldValue(name, value)` | Change one field | +| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` | +| `setFieldTouched(name, value?)` | Set touched explicitly | +| `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 @@ -247,6 +259,13 @@ Any type you have not registered falls back to one of these. `file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number` emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings. +Since 1.7.0 a default renderer also renders its validation message, as +`