Skip to content

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 blurred

Your component's only job:

WhatHow
Report a value changeemit('update:modelValue', newValue)
Trigger validationemit('blur') — fires validation when validateOn is 'blur' or 'eager'
Show errorsRead props.error / props.touched (or use useFormField)

The FormFieldProps contract

Every component that plugs into the library must declare these props and two emits:

ts
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

vue
<!-- 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

ts
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:

vue
<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

ts
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)

ts
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

ts
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

PresetExample output
phone-ru+7 (916) 123-45-67
phone-eu+49 (30) 123-45-67
date01.01.2024
inn123456789012
ibanGB29 NWBK 6016 1331 9268 19
ts
{ type: 'text', name: 'phone', mask: { preset: 'phone-ru' } }

Custom patterns

# = digit, A = letter (uppercased), anything else = literal.

ts
{ type: 'text', name: 'postcode', mask: { pattern: 'AA####' } }  // AB1234

Standalone API

ts
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)