@dynamic-field-kit/vue
v1.7.0
Published
Vue 3 renderer for dynamic-field-kit
Maintainers
Readme
@dynamic-field-kit/vue
Vue 3 adapter for @dynamic-field-kit/core.
Requires Vue 3.2 or newer (peerDependencies: vue ^3.2.0). The floor is
3.2 because useDynamicForm uses getCurrentScope / onScopeDispose to
abort an in-flight validation when the owning scope goes away.
scripts/verify-vue-peer-range.js renders the packed tarballs under 3.2 and
the newest 3.x in CI, so the range is proven rather than asserted.
This package provides Vue components that render FieldDescription[] and resolve field renderers through the shared registry used by dynamic-field-kit.
Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ — tabs for the
basic schema, the enterprise features (useDynamicForm, HTML5 renderers, blur
wiring, DevTools) and the multi-step wizard.
Install
npm install @dynamic-field-kit/core @dynamic-field-kit/vue vueNote: @dynamic-field-kit/core and vue are peer dependencies — this adapter does not bundle or auto-install them, so add them to your app explicitly (as shown above). Keep a single @dynamic-field-kit/core version across all adapters so they share one registry.
Exports
DynamicInputFieldInputMultiFieldInputlayoutRegistryfieldRegistryFieldRegistry(class, for scoped registries)provideFieldRegistry/useFieldRegistry/FieldRegistryKeyFieldDescriptionFieldTypeKeyFieldRendererPropsPropertiesLayoutConfiguseDynamicFormDynamicFormDevToolsdefaultRenderersMap/getDefaultRenderer
Re-exported from @dynamic-field-kit/core so a consumer app rarely has to import
both packages:
validateField/validateFieldAsync— one field, returnsstring[]validateFields/validateFieldsAsync— a whole schema, returnsValidationResultcollectFieldPaths— the leaf paths a schema actually has in the data (contacts[0].email)indexGroupPathMap— index an error or touched map by repeatable-group itemmakeErrorId— the id a renderer puts on its message element soaria-describedbyresolvescreateOptionsLoader/isAsyncOptions— the async options enginecreateMessageResolver/setDefaultMessages/MessageCatalog— validation message catalogresolveDisabled/resolveReadOnly/resolveOptions— resolve a field's dynamic conditions and optionsvalidators— the built-in validator helpers (required,email,minLength,compose, …)ValidationResult/ValidationContext
useDynamicForm keeps live validation synchronous - a validator declared or
detected as async is never invoked on that path. Its handleSubmit runs one
async-capable pass, and validateAsync() is there when you need that answer
before submit. Runs are latest-wins: typing aborts the live run in flight, so a
stale result cannot overwrite a newer one, and a submit validates the snapshot
it was given under a controller of its own, so editing mid-submit no longer
cancels it. Declare a Promise-returning validator with
validationMode: 'async' and read context.signal (the fourth argument) to
cancel the request itself. See the
core README
for the full rules.
For a complete UI integration, see the Vuetify recipe.
Default layouts are registered automatically when you import the package root.
Built-in layouts:
columnrowgrid(alias:grid-2)responsive
Register field renderers
Register Vue renderers before rendering your form:
import { defineComponent, h } from 'vue';
import { fieldRegistry } from '@dynamic-field-kit/vue';
fieldRegistry.register(
'text',
defineComponent({
name: 'TextFieldRenderer',
props: {
value: { type: String, default: '' },
label: { type: String, default: '' },
},
emits: ['update:value'],
setup(props, { emit }) {
return () =>
h('label', { style: { display: 'grid', gap: '4px' } }, [
h('span', props.label),
h('input', {
value: props.value ?? '',
onInput: (event: Event) => {
emit('update:value', (event.target as HTMLInputElement).value);
},
}),
]);
},
}),
);Basic usage
<script setup lang="ts">
import { ref } from 'vue';
import { MultiFieldInput } from '@dynamic-field-kit/vue';
import type { FieldDescription } from '@dynamic-field-kit/core';
const fields: FieldDescription[] = [
{ name: 'username', type: 'text', label: 'Username' },
{ name: 'email', type: 'text', label: 'Email' },
];
const formData = ref({});
function handleChange(data: Record<string, unknown>) {
formData.value = data;
}
</script>
<template>
<MultiFieldInput
:fieldDescriptions="fields"
:properties="formData"
:onChange="handleChange"
/>
</template>Form state (useDynamicForm)
Holds data, errors, touched and submission state for a set of fields. Everything
is a ref (or computed), so read through .value in <script setup>.
<script setup lang="ts">
import { useDynamicForm, MultiFieldInput } from '@dynamic-field-kit/vue';
const form = useDynamicForm({
fields,
initialValues: { country: 'VN' },
validateOnBlur: true, // default
validateOnChange: false, // default
messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions
});
const onSubmit = form.handleSubmit((data) => save(data));
</script>
<template>
<form @submit="onSubmit">
<MultiFieldInput :field-descriptions="fields" :form="form" />
<button :disabled="form.isSubmitting.value">Save</button>
</form>
</template>:form is shorthand for five state/callback props, and is the recommended wiring:
<MultiFieldInput
:field-descriptions="fields"
:properties="form.data.value"
:on-change="form.handleChange"
:on-blur-field="form.handleBlur"
:touched="form.touched.value"
:errors="form.errors.value"
/>Passing touched and errors makes the form store the renderer's source of
truth. handleSubmit marks
every field touched before validating, so a renderer that gates its error on
touched shows it even for fields the user never focused. reset() clears
touched the same way. Individually passed props win over the ones form
derives.
| Member | Description |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| data | Ref of the form data, with computeValue fields applied |
| errors | Ref<Record<string, string[]>>, keyed like validateFields |
| isValid / isDirty | Live synchronous validity (computed) / changed state (Ref) |
| baselineValues | Ref 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 overisValidalone: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 — pass to MultiFieldInput's 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 a submit handler; calls preventDefault, validates, then dispatches |
onBlurField is what connects handleBlur — and therefore touched and
validateOnBlur — to the rendered form.
Leave touched off and MultiFieldInput falls back to tracking it internally
from blur alone, as it always did. In that mode nothing outside the component
can clear it, so it exposes resetTouched(), setFieldTouched(name, value?)
and getTouched() on its instance:
<MultiFieldInput ref="formRef" :field-descriptions="fields" />
<script setup>
const formRef = ref();
// after a successful submit
formRef.value.resetTouched();
</script>Field ids
Each field renders with id={`${idPrefix}-${name}`}, where idPrefix
defaults to a value unique to the MultiFieldInput instance. Two forms
containing a field of the same name therefore no longer emit the same DOM id
twice.
<!-- pinned ids — reproduces the pre-1.6 `dfk-field-title` -->
<MultiFieldInput :field-descriptions="fields" id-prefix="dfk-field" />For one field, set id on its FieldDescription; it wins over the prefix.
Default renderers
text · number · password · email · textarea · checkbox · select ·
radio · range · file · date · time · datetime-local · switch
Any type you have not registered falls back to one of these.
import {
defaultRenderersMap,
getDefaultRenderer,
} from '@dynamic-field-kit/vue';
const Base = getDefaultRenderer('date'); // undefined for an unknown typefile 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
<div id="{fieldId}-error" class="dfk-field-error" role="alert">, 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
<DynamicFormDevTools
:data="form.data.value"
:errors="form.errors.value"
:touched="form.touched.value"
:is-dirty="form.isDirty.value"
:fields="fields"
position="bottom-right"
/>A floating overlay with data / errors / meta / fields tabs. The collapsed button carries a red badge with the number of fields in error.
Layouts
Use a layout name:
<MultiFieldInput :fieldDescriptions="fields" layout="grid" />Use a layout config object:
<MultiFieldInput
:fieldDescriptions="fields"
:layout="{ type: 'grid', columns: 3, gap: 12 }"
/>Use the built-in responsive layout:
<MultiFieldInput
:fieldDescriptions="fields"
:layout="{
type: 'responsive',
mobile: 'column',
desktop: { type: 'grid', columns: 2, gap: 12 },
}"
/>Register a custom layout:
import { h } from 'vue';
import { layoutRegistry } from '@dynamic-field-kit/vue';
layoutRegistry.register('stack-tight', ({ children }) => {
return h('div', { style: { display: 'grid', gap: '8px' } }, children);
});Derived fields with computeValue
Give a field a computeValue to derive its value from the rest of the form data whenever any field changes:
const fields: FieldDescription[] = [
{ name: 'firstName', type: 'text' },
{ name: 'lastName', type: 'text' },
{
name: 'fullName',
type: 'text',
computeValue: (data) =>
`${data.firstName ?? ''} ${data.lastName ?? ''}`.trim(),
},
];Validation & conditions
Declare a validate hook and dynamic disabledCondition/readOnlyCondition;
your renderer receives error, disabled, and readOnly, and MultiFieldInput
emits onValidityChange:
<MultiFieldInput
:fieldDescriptions="fields"
:properties="formData"
:onChange="handleChange"
:onValidityChange="({ valid }) => (canSubmit = valid)"
/>A renderer reads the props (error, disabled, readOnly) it declares, the
same way it reads value/label.
Validation messages
Set the built-in validators' messages once per form instead of on every field:
const form = useDynamicForm({
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. 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.
Async options
options may return a promise. The renderer receives optionsStatus
('idle' | 'loading' | 'ready' | 'error'), optionsError and
onOptionsQuery:
{
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.
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.
const fields: FieldDescription[] = [
{
name: 'contacts',
type: 'group',
label: 'Contacts',
fields: [
{ name: 'email', type: 'text', label: 'Email' },
{ name: 'phone', type: 'text', label: 'Phone' },
],
defaultItem: { email: '', phone: '' },
keyField: 'id', // optional: stable list key instead of the array index
minItems: 1,
maxItems: 5,
},
];<MultiFieldInput :fieldDescriptions="fields" />Scoped registries
fieldRegistry is a process-wide singleton. To give a component subtree its own renderers, create an isolated FieldRegistry and provide it from a parent's setup() with provideFieldRegistry. Descendants that don't have a provider keep using the global singleton.
import { FieldRegistry, provideFieldRegistry } from '@dynamic-field-kit/vue';
// in a parent component's setup()
const registry = new FieldRegistry();
registry.register('text', MyTextRenderer);
provideFieldRegistry(registry);Type augmentation
import '@dynamic-field-kit/core';
declare module '@dynamic-field-kit/core' {
interface FieldTypeMap {
text: string;
number: number;
}
}Notes
@dynamic-field-kit/coreowns the schema types and shared runtime registry.@dynamic-field-kit/vueis the package you should import when registering Vue renderers.MultiFieldInputfilters fields usingappearConditionand derives fields usingcomputeValue.DynamicInputrendersUnknown field type: ...when a renderer is missing.- Fields with
fieldsrender as repeatable groups instead of going throughfieldRegistry.
License
MIT
