Controls
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 viatype, suitable for reusable controls - Direct injection: Pass a component instance directly via
componentin the schema, suitable for one-off usage
Built-in Controls
Default Mapping
| afz Method | Default Control | Description |
|---|---|---|
afz.string() | UInput | Text input |
afz.number() | UInputNumber | Number input |
afz.boolean() | UCheckbox | Checkbox |
afz.enum() | USelect | Dropdown select |
afz.file() | UFileUpload | File upload |
afz.calendarDate() | DatePicker | Date picker |
inputDate | UInputDate | Date input |
inputTime | UInputTime | Time input |
Available type Values
Specify the following built-in controls in the schema via the type field:
| type value | Control Component | Description |
|---|---|---|
textarea | UTextarea | Multi-line text input |
switch | USwitch | Toggle switch |
slider | USlider | Slider selector |
pinInput | UPinInput | PIN input |
inputTags | UInputTags | Tag input |
selectMenu | USelectMenu | Dropdown menu selector |
inputMenu | UInputMenu | Input menu |
listbox | UListbox | Listbox selector |
checkboxGroup | UCheckboxGroup | Checkbox group |
radioGroup | URadioGroup | Radio button group |
inputDate | UInputDate | Date input |
inputTime | UInputTime | Time input |
withClear | WithClear | Input with clear button |
withPasswordToggle | WithPasswordToggle | Input with password visibility toggle |
withCopy | WithCopy | Input with copy button |
withCharacterLimit | WithCharacterLimit | Input with character counter |
withFloatingLabel | WithFloatingLabel | Input with floating label |
asPhoneNumberInput | AsPhoneNumberInput | Phone number input |
colorChooser | ColorChooser | Color picker |
starRating | StarRating | Star rating |
slideVerify | SlideVerify | Slide verification |
pillGroup | PillGroup | Pill 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:
<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)
})
})
defineControl to register custom controls for more stable controlProps / controlSlots type hints.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' })
})
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:
<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>
<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>