Custom Components
Custom field components
A custom component is a pure presentation layer — it receives pre-computed validation state as props and signals changes back to the form. No validation logic lives inside the component itself.
How validation flows
useForm
├─ validators / asyncValidators / required ← defined in the schema
├─ errors.value['fieldName'] = ['Too short'] ← computed internally
└─ passes to your component via props:
error: string[] — list of error messages
touched: boolean — whether the field has been blurredYour component's only job:
| What | How |
|---|---|
| Report a value change | emit('update:modelValue', newValue) |
| Trigger validation | emit('blur') — fires validation when validateOn is 'blur' or 'eager' |
| Show errors | Read props.error / props.touched (or use useFormField) |
The FormFieldProps contract
Every component that plugs into the library must declare these props and two emits:
import type { FormFieldProps } from '@macrulez/vue-form-schema'
// props
const props = defineProps<FormFieldProps>()
// {
// field: FieldDefinition — the full field config (validators, label, …)
// modelValue: unknown — current value from form state
// error: string[] — validation errors (empty when valid)
// touched: boolean — true after first blur
// }
// emits
const emit = defineEmits<{
'update:modelValue': [value: unknown]
blur: []
}>()Complete example — custom phone input
<!-- MyPhoneInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useFormField } from '@macrulez/vue-form-schema'
import type { FormFieldProps } from '@macrulez/vue-form-schema'
const props = defineProps<FormFieldProps>()
const emit = defineEmits<{
'update:modelValue': [value: string]
blur: []
}>()
const { hasError, errorMessage, isRequired } = useFormField(props)
// strip non-digits for storage, display formatted
const display = computed(() =>
String(props.modelValue ?? '')
.replace(/\D/g, '')
.replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3'),
)
</script>
<template>
<div class="field">
<label :for="field.name">
{{ field.label }}
<span v-if="isRequired" aria-hidden="true">*</span>
</label>
<input
:id="field.name"
type="tel"
:value="display"
:aria-invalid="hasError ? 'true' : 'false'"
:aria-describedby="hasError ? `${field.name}-error` : undefined"
@input="
emit('update:modelValue', ($event.target as HTMLInputElement).value.replace(/\D/g, ''))
"
@blur="emit('blur')"
/>
<p v-if="hasError" :id="`${field.name}-error`" role="alert">
{{ errorMessage }}
</p>
</div>
</template>Attach to a field via field.component
import MyPhoneInput from './MyPhoneInput.vue'
import { minLength, pattern } from '@macrulez/vue-form-schema'
const schema: FieldDefinition[] = [
{
type: 'text',
name: 'phone',
label: 'Phone number',
component: MyPhoneInput, // ← your component renders instead of TextField
required: true,
validators: [
minLength(10, 'Enter a full phone number'),
pattern(/^\d{10}$/, 'Digits only, 10 characters'),
],
},
]Using without FormRenderer (manual wiring)
If you render fields yourself — without FormRenderer — wire errors and the touch handler directly:
<script setup lang="ts">
import { useForm } from '@macrulez/vue-form-schema'
import MyPhoneInput from './MyPhoneInput.vue'
const form = useForm({ schema, validateOn: 'blur' })
const touchField = (form as any).touchField // exposed internally
</script>
<template>
<form @submit.prevent="form.submit()">
<MyPhoneInput
:field="form.fields.value[0]"
:model-value="form.values.value.phone"
:error="form.errors.value.phone ?? []"
:touched="form.touched.value.phone ?? false"
@update:model-value="form.setField('phone', $event)"
@blur="touchField('phone')"
/>
<button type="submit">Save</button>
</form>
</template>useFormField helper — computed shortcuts
import { useFormField } from '@macrulez/vue-form-schema'
const props = defineProps<FormFieldProps>()
const {
hasError, // ComputedRef<boolean> — touched && error.length > 0
errorMessage, // ComputedRef<string | null> — first error, or null
allErrors, // ComputedRef<string[]> — all errors when touched, else []
isRequired, // ComputedRef<boolean>
isDisabled, // ComputedRef<boolean>
} = useFormField(props)Component registry
Replace all instances of a field type across a subtree — useful for integrating UI libraries.
App-level (Vue plugin)
import { createApp } from 'vue'
import { createFormRegistry } from '@macrulez/vue-form-schema'
import { ElInput, ElSelect } from 'element-plus'
createApp(App)
.use(createFormRegistry({ text: ElInput, select: ElSelect }))
.mount('#app')Subtree-level
import { provideRegistry } from '@macrulez/vue-form-schema'
// Inside a component's setup()
provideRegistry({ checkbox: MyToggle })Component priority: field.component > FormRenderer :components prop > registry > built-in defaults.
Input masking
Masks format user input in real time. Applied automatically in FormRenderer; also usable standalone.
Presets
| Preset | Example output |
|---|---|
phone-ru | +7 (916) 123-45-67 |
phone-eu | +49 (30) 123-45-67 |
date | 01.01.2024 |
inn | 123456789012 |
iban | GB29 NWBK 6016 1331 9268 19 |
{ type: 'text', name: 'phone', mask: { preset: 'phone-ru' } }Custom patterns
# = digit, A = letter (uppercased), anything else = literal.
{ type: 'text', name: 'postcode', mask: { pattern: 'AA####' } } // AB1234Standalone API
import { applyMask, removeMask, bindMask } from '@macrulez/vue-form-schema'
applyMask('9161234567', { preset: 'phone-ru' }) // '+7 (916) 123-45-67'
removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' }) // '9161234567'
const cleanup = bindMask(inputEl, { preset: 'date' })
onUnmounted(cleanup)