Controls

View source
Map schema fields to Nuxt UI controls via the type field, or register and inject custom Vue components to extend form fields.

Overview

AutoForm maps schema fields to specific rendering controls via the type field, and supports two ways to extend with custom controls:

  • Register type: Register a control type via useAutoForm(), then reference it in the schema via type, suitable for reusable controls
  • Direct injection: Pass a component instance directly via component in the schema, suitable for one-off usage

Built-in Controls

Default Mapping

afz MethodDefault ControlDescription
afz.string()UInputText input
afz.number()UInputNumberNumber input
afz.boolean()UCheckboxCheckbox
afz.enum()USelectDropdown select
afz.file()UFileUploadFile upload
afz.calendarDate()DatePickerDate picker
inputDateUInputDateDate input
inputTimeUInputTimeTime input

Available type Values

Specify the following built-in controls in the schema via the type field:

type valueControl ComponentDescription
textareaUTextareaMulti-line text input
switchUSwitchToggle switch
sliderUSliderSlider selector
pinInputUPinInputPIN input
inputTagsUInputTagsTag input
selectMenuUSelectMenuDropdown menu selector
inputMenuUInputMenuInput menu
listboxUListboxListbox selector
checkboxGroupUCheckboxGroupCheckbox group
radioGroupURadioGroupRadio button group
inputDateUInputDateDate input
inputTimeUInputTimeTime input
withClearWithClearInput with clear button
withPasswordToggleWithPasswordToggleInput with password visibility toggle
withCopyWithCopyInput with copy button
withCharacterLimitWithCharacterLimitInput with character counter
withFloatingLabelWithFloatingLabelInput with floating label
asPhoneNumberInputAsPhoneNumberInputPhone number input
colorChooserColorChooserColor picker
starRatingStarRatingStar rating
slideVerifySlideVerifySlide verification
pillGroupPillGroupPill radio group

Controls Overview

Reference built-in controls in the schema via type, demonstrating all built-in controls grouped by text, number, boolean, selection, date, and custom:

0/50
+86
<script setup lang="ts">
import { UTabs } from '#components'
import type { z } from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  $tabs: afz.layout({
    component: UTabs,
    props: {
      ui: { content: 'pt-4 space-y-4' },
      items: [
        { label: '文本', icon: 'i-lucide-type', slot: 'text' },
        { label: '数字', icon: 'i-lucide-hash', slot: 'number' },
        { label: '布尔', icon: 'i-lucide-toggle-left', slot: 'boolean' },
        { label: '选择', icon: 'i-lucide-list', slot: 'select' },
        { label: '日期', icon: 'i-lucide-calendar', slot: 'date' },
        { label: '自定义', icon: 'i-lucide-palette', slot: 'custom' }
      ]
    },
    fieldSlots: {
      string: 'text',
      textarea: 'text',
      withClear: 'text',
      withCopy: 'text',
      withPasswordToggle: 'text',
      withCharacterLimit: 'text',
      asPhoneNumberInput: 'text',
      withFloatingLabel: 'text',
      inputMenu: 'text',
      pinInput: 'text',

      number: 'number',
      slider: 'number',
      starRating: 'number',

      boolean: 'boolean',
      switch: 'boolean',
      slideVerify: 'boolean',

      enum: 'select',
      selectMenu: 'select',
      radioGroup: 'select',
      listbox: 'select',
      checkboxGroup: 'select',
      inputTags: 'select',
      pillGroup: 'select',

      calendarDate: 'date',
      inputDate: 'date',
      inputTime: 'date',

      colorChooser: 'custom'
    },
    fields: {
      string: afz.string({ controlProps: { placeholder: '默认文本' } }).meta({ label: 'Input' }),
      textarea: afz.string({ type: 'textarea', controlProps: { rows: 2 } }).meta({ label: 'Textarea' }),
      withClear: afz.string({ type: 'withClear' }).default('movk').meta({ label: 'WithClear' }),
      withCopy: afz.string({ type: 'withCopy' }).default('https://movk.dev').meta({ label: 'WithCopy' }),
      withPasswordToggle: afz.string({ type: 'withPasswordToggle' }).default('s3cret').meta({ label: 'WithPasswordToggle' }),
      withCharacterLimit: afz.string({ type: 'withCharacterLimit', controlProps: { maxlength: 30 } }).meta({ label: 'WithCharacterLimit' }),
      asPhoneNumberInput: afz.string({ type: 'asPhoneNumberInput' }).default('13800138000').meta({ label: 'AsPhoneNumberInput' }),
      withFloatingLabel: afz.string({ type: 'withFloatingLabel' }).meta({ label: 'WithFloatingLabel' }),
      inputMenu: afz.string({ type: 'inputMenu', controlProps: { items: ['Vue', 'Nuxt', 'Nitro'] } }).meta({ label: 'InputMenu' }),
      pinInput: afz.array(afz.string(), { type: 'pinInput', controlProps: { length: 4 } }).meta({ label: 'PinInput' }),

      number: afz.number({ controlProps: { placeholder: '默认数字' } }).meta({ label: 'InputNumber' }),
      slider: afz.number({ type: 'slider', controlProps: { min: 0, max: 100, step: 5 } }).default(40).meta({ label: 'Slider' }),
      starRating: afz.number({ type: 'starRating', controlProps: { allowHalf: true } }).default(3.5).meta({ label: 'StarRating' }),

      boolean: afz.boolean().meta({ label: 'Checkbox' }),
      switch: afz.boolean({ type: 'switch' }).meta({ label: 'Switch' }),
      slideVerify: afz.boolean({ type: 'slideVerify' }).meta({ label: 'SlideVerify' }),

      enum: afz.enum(['low', 'medium', 'high']).default('medium').meta({ label: 'Select' }),
      selectMenu: afz.enum(['北京', '上海', '广州', '深圳'], { type: 'selectMenu' }).meta({ label: 'SelectMenu' }),
      radioGroup: afz.enum(['', '', '其他'], { type: 'radioGroup' }).meta({ label: 'RadioGroup' }),
      listbox: afz.enum(['草稿', '已发布', '已归档'], { type: 'listbox' }).default('草稿').meta({ label: 'Listbox' }),
      checkboxGroup: afz.array(afz.string(), { type: 'checkboxGroup', controlProps: { items: ['阅读', '游戏', '运动'] } }).meta({ label: 'CheckboxGroup' }),
      inputTags: afz.array(afz.string(), { type: 'inputTags' }).default(['nuxt', 'vue']).meta({ label: 'InputTags' }),
      pillGroup: afz.enum(['全部', '前端', '后端'], { type: 'pillGroup' }).default('全部').meta({ label: 'PillGroup' }),

      calendarDate: afz.calendarDate({ controlProps: { valueFormat: 'iso' } }).meta({ label: 'DatePicker' }),
      inputDate: afz.inputDate().meta({ label: 'InputDate' }),
      inputTime: afz.inputTime().meta({ label: 'InputTime' }),

      colorChooser: afz.string({ type: 'colorChooser', controlProps: { formats: ['hex', 'rgb'] } }).default('#0ea5e9').meta({ label: 'ColorChooser' })
    }
  })
})

const state = reactive<Partial<z.output<typeof schema>>>({})
</script>

<template>
  <MAutoForm :schema="schema" :state="state" />
</template>

Custom Controls

When built-in controls cannot meet your needs, you can integrate custom Vue components through registration or direct injection.

Register a Control

Register a control via useAutoForm(), then reference it in the schema via the type field:

import { TagSelector } from '#components'

const { afz, controls } = useAutoForm({
  tagSelector: defineControl({
    component: TagSelector,
    controlProps: { class: 'w-full' } // global controlProps (can be overridden at field level)
  })
})
It is recommended to use defineControl to register custom controls for more stable controlProps / controlSlots type hints.
The plain object syntax (tagSelector: { component: TagSelector }) is still supported and also benefits from type inference.

Use the registered control type in the schema:

const schema = afz.object({
  skills: afz
    .array(afz.string(), {
      type: 'tagSelector',       // specify control type
      controlProps: {
        options: ['Vue.js', 'Nuxt', 'TypeScript'],
        max: 5
      }
    })
    .meta({ label: 'Skill Tags' })
})

Pass controls to MAutoForm:

<MAutoForm
  :schema="schema"
  :state="form"
  :controls="controls"
  @submit="onSubmit"
/>

Inject Component Directly

No registration needed — pass a component instance directly in the schema meta:

import { StarRating } from '#components'

const schema = afz.object({
  rating: afz.number({
    component: StarRating,
    controlProps: { max: 10 }
  }).meta({ label: 'Rating' })
})
When injecting a component directly, type and component are mutually exclusive; if both are present, component takes precedence.

Component Specification

Custom control components must meet the following requirements.

Support v-model

<script setup>
// Recommended syntax for Vue 3.4+
const model = defineModel()

// Or the traditional approach
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
</script>

Accept controlProps

Fields in controlProps are passed through as props to the control component. The component should declare the corresponding props:

<script setup>
defineProps<{
  modelValue?: string[]
  options?: string[]  // corresponds to controlProps.options
  max?: number        // corresponds to controlProps.max
  disabled?: boolean
}>()
</script>

Example

Register TagSelector as the tagSelector control type via defineControl, reference it with type: 'tagSelector' in the schema, and pass candidate tags and maximum selection count via controlProps:

您的主要技术方向

最多选择 5 项技能

Vue.jsNuxtTypeScriptReactNode.jsPythonGraphQLDockerTailwind CSSVite

已选 0 / 5

开启后将对招聘者可见
<script lang="ts" setup>
import type { FormSubmitEvent } from '@nuxt/ui'
import type { z } from 'zod'
import { TagSelector } from '#components'

const toast = useToast()

const { afz, controls } = useAutoForm({
  tagSelector: defineControl({
    component: TagSelector,
    controlProps: { class: 'w-full' }
  })
})

const SKILL_OPTIONS = [
  'Vue.js', 'Nuxt', 'TypeScript', 'React', 'Node.js',
  'Python', 'GraphQL', 'Docker', 'Tailwind CSS', 'Vite'
]

const ROLE_OPTIONS = {
  frontend: '前端开发',
  backend: '后端开发',
  fullstack: '全栈开发',
  devops: 'DevOps',
  designer: 'UI / 设计'
}

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入您的姓名' } })
    .min(2, '姓名至少 2 个字符')
    .meta({ label: '姓名' }),

  role: afz
    .enum(Object.keys(ROLE_OPTIONS) as [string, ...string[]])
    .default('fullstack')
    .meta({ label: '职位', description: '您的主要技术方向' }),

  skills: afz
    .array(afz.string(), {
      type: 'tagSelector',
      controlProps: {
        options: SKILL_OPTIONS,
        max: 5
      }
    })
    .default([])
    .meta({ label: '技能标签', description: '最多选择 5 项技能' }),

  bio: afz
    .string({
      type: 'textarea',
      controlProps: { placeholder: '简短介绍一下自己...' }
    })
    .optional()
    .meta({ label: '个人简介' }),

  openToWork: afz
    .boolean()
    .default(false)
    .meta({ label: '求职中', hint: '开启后将对招聘者可见' })
})

type Schema = z.output<typeof schema>

const form = ref<Partial<Schema>>({})

async function onSubmit(event: FormSubmitEvent<Schema>) {
  toast.add({
    title: '提交成功',
    color: 'success',
    description: JSON.stringify(event.data, null, 2)
  })
}
</script>

<template>
  <MAutoForm
    :schema="schema"
    :state="form"
    :controls="controls"
    @submit="onSubmit"
  />
</template>
app/components/TagSelector.vue
<script setup lang="ts">
import { UBadge } from '#components'

export interface TagSelectorProps {
  /**
   * List of selectable tags
   */
  options?: string[]
  /**
   * Maximum number of selections (unlimited if not set)
   */
  max?: number
  /**
   * Disabled state
   */
  disabled?: boolean
}

const props = withDefaults(defineProps<TagSelectorProps>(), {
  options: () => [],
  disabled: false
})

const model = defineModel<string[]>({ default: () => [] })

function isSelected(tag: string) {
  return (model.value ?? []).includes(tag)
}

function isMaxReached(tag: string) {
  const current = model.value ?? []
  return !!props.max && current.length >= props.max && !current.includes(tag)
}

function toggle(tag: string) {
  if (props.disabled || isMaxReached(tag)) return

  const current = model.value ?? []
  model.value = current.includes(tag)
    ? current.filter(t => t !== tag)
    : [...current, tag]
}
</script>

<template>
  <div class="space-y-2">
    <div
      v-if="options.length === 0"
      class="text-sm text-muted"
    >
      No tags available
    </div>
    <div
      v-else
      class="flex flex-wrap gap-2"
    >
      <UBadge
        v-for="tag in options"
        :key="tag"
        :label="tag"
        :variant="isSelected(tag) ? 'solid' : 'outline'"
        color="primary"
        class="transition-opacity select-none"
        :class="[
          disabled || isMaxReached(tag)
            ? 'opacity-40 cursor-not-allowed'
            : 'cursor-pointer hover:opacity-80'
        ]"
        @click="toggle(tag)"
      />
    </div>
    <p
      v-if="max"
      class="text-xs text-muted"
    >
      Selected {{ (model ?? []).length }} / {{ max }}
    </p>
  </div>
</template>
Copyright © 2025 - 2026 YiXuan - MIT License