SearchForm

View source
A schema-driven, collapsible search form component.

Introduction

MSearchForm is a schema-driven search form component with built-in grid layout, search/reset buttons, and collapse behavior. When there are many search fields, items exceeding the visible row count are automatically collapsed. Users can expand or collapse them using the toggle button.

Reuses the AutoForm infrastructure (schema introspection, control mapping, field renderers) and defines search fields via Zod schema.

Usage

Renders fields from an AutoForm schema. cols controls the grid. Built-in search and reset buttons — clicking "Search" triggers validation and emits @submit:

<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入姓名' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入部门' } }).meta({ label: '部门' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入关键词' } }).meta({ label: '关键词' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入邮箱' } }).meta({ label: '邮箱' }).optional()
})

const state = ref<Partial<z.output<typeof schema>>>({})
const result = ref('')

function handleSearch(event: FormSubmitEvent<Record<string, unknown>>) {
  result.value = JSON.stringify(event.data, null, 2)
}

function handleReset() {
  result.value = ''
}
</script>

<template>
  <div class="space-y-4">
    <MSearchForm
      v-model="state"
      :schema="schema"
      @submit="handleSearch"
      @reset="handleReset"
    />
    <pre v-if="result" class="text-sm bg-muted p-3 rounded-(--ui-radius)">{{ result }}</pre>
  </div>
</template>

v-model Binding

v-model binds form data bidirectionally. The initial value passed in is recorded as the reset baseline — clicking "Reset" restores to that initial value rather than clearing the form.

{
  "name": "张三",
  "status": "启用"
}
<script setup lang="ts">
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入姓名' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入关键词' } }).meta({ label: '关键词' }).optional()
})

const state = ref<Partial<z.output<typeof schema>>>({
  name: '张三',
  status: '启用'
})
</script>

<template>
  <div class="space-y-4">
    <MSearchForm v-model="state" :schema="schema" />
    <pre class="text-sm bg-muted p-3 rounded-(--ui-radius)">{{ JSON.stringify(state, null, 2) }}</pre>
    <UButton
      size="sm"
      color="neutral"
      variant="outline"
      @click="state = { name: '李四', status: '禁用', keyword: '测试' }"
    >
      外部设值
    </UButton>
  </div>
</template>

cols Grid Columns

cols controls the number of grid columns: pass a number for a fixed count, or a breakpoint object to switch columns across sm, md, lg, xl breakpoints.

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" />
</template>

调整浏览器窗口宽度查看效果:sm=1, md=2, lg=3, xl=4

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})

const params = ref({})
</script>

<template>
  <div class="space-y-2">
    <p class="text-sm text-muted">
      调整浏览器窗口宽度查看效果:sm=1, md=2, lg=3, xl=4
    </p>
    <MSearchForm
      v-model="params"
      :schema="schema"
      :cols="{ sm: 1, md: 2, lg: 3, xl: 4 }"
    />
  </div>
</template>

visibleRows Collapse Behavior

visibleRows controls the number of visible rows. Fields exceeding this count collapse into the expanded area. Drag cols and visibleRows below to change the collapse threshold in real time.

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  phone: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '手机号' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" />
</template>

expanded Controlled Expand

v-model:expanded takes over the expanded state, taking priority over defaultExpanded. It can be driven by external buttons or logic.

expanded = false
<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  phone: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '手机号' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})

const expanded = ref(false)
</script>

<template>
  <div class="space-y-3">
    <div class="flex items-center gap-2 text-sm text-muted">
      <UButton size="sm" color="neutral" variant="outline" @click="expanded = !expanded">
        外部{{ expanded ? '收起' : '展开' }}
      </UButton>
      <span>expanded = {{ expanded }}</span>
    </div>
    <MSearchForm v-model:expanded="expanded" :schema="schema" />
  </div>
</template>

expandText / collapseText / icon Toggle Button

expandText and collapseText customize the expand/collapse label text. icon switches the button icon. collapseButtonProps passes through button attributes.

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  phone: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '手机号' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" :collapse-button-props="{ color: 'primary', variant: 'soft' }" />
</template>

actions Action Buttons

The actions array extends or trims buttons:

  • Built-in key: search automatically binds to submit; key: reset automatically binds to reset
  • Custom key requires an onClick(ctx) handler where ctx contains state, errors, search, reset, clear, toggle, loading, and expanded
  • Pass actions: [] to disable all built-in buttons and fully customize via the actions slot

仅保留搜索按钮(actions 只传 search)

关闭全部内置按钮(actions: []),通过 actions slot 完全自定义

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '姓名' })
    .optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  department: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '部门' })
    .optional(),
  keyword: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '关键词' })
    .optional()
})
</script>

<template>
  <div class="space-y-6">
    <div class="space-y-2">
      <p class="text-sm text-muted">仅保留搜索按钮(actions 只传 search)</p>
      <MSearchForm
        :schema="schema"
        :actions="[{ key: 'search', label: '搜索', icon: 'i-lucide-search', type: 'submit' }]"
      />
    </div>
    <div class="space-y-2">
      <p class="text-sm text-muted">
        关闭全部内置按钮(actions: []),通过 actions slot 完全自定义
      </p>
      <MSearchForm :schema="schema" :actions="[]">
        <template #actions="{ search, reset }">
          <div class="flex items-end gap-2 justify-end">
            <UButton color="primary" variant="solid" icon="i-lucide-filter" @click="search">
              筛选
            </UButton>
            <UButton color="neutral" variant="ghost" icon="i-lucide-x" @click="reset">
              清空
            </UButton>
          </div>
        </template>
      </MSearchForm>
    </div>
  </div>
</template>
<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '姓名' })
    .optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  keyword: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '关键词' })
    .optional(),
  department: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '部门' })
    .optional()
})

function onExport() {
  console.log('导出')
}
</script>

<template>
  <MSearchForm
    :schema="schema"
    :actions="[
      {
        key: 'search',
        label: '查询',
        icon: 'i-lucide-search',
        type: 'submit',
        color: 'primary',
        variant: 'solid'
      },
      {
        key: 'reset',
        label: '清空',
        icon: 'i-lucide-rotate-ccw',
        color: 'error',
        variant: 'outline'
      },
      {
        key: 'export',
        label: '导出',
        icon: 'i-lucide-download',
        color: 'primary',
        variant: 'soft',
        onClick: onExport
      }
    ]"
  />
</template>

Examples

Taking Over the Actions Area

The #actions slot exposes search, clear, and loading, allowing custom buttons to replace the default actions area:

<script setup lang="ts">
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '姓名' })
    .optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional()
})

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

<template>
  <MSearchForm v-model="state" :schema="schema" :cols="3">
    <template #actions="{ search, clear, loading }">
      <div class="flex items-end gap-2">
        <UButton
          color="primary"
          variant="solid"
          icon="i-lucide-filter"
          :loading="loading"
          @click="search"
        >
          筛选
        </UButton>
        <UButton color="neutral" variant="ghost" icon="i-lucide-x" @click="clear"> 清空 </UButton>
      </div>
    </template>
  </MSearchForm>
</template>

Extending Layout Areas

header, footer, and extraActions insert supplementary content. Slot props update with expand state and form values:

#header · expanded=false
#footer · 当前关键词: —
<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" :cols="3">
    <template #header="{ expanded }">
      <div class="rounded border border-dashed border-primary/40 bg-primary/5 px-3 py-2 text-xs text-primary">
        #header · expanded={{ expanded }}
      </div>
    </template>
    <template #extraActions>
      <UButton size="sm" color="neutral" variant="outline" icon="i-lucide-save">
        保存方案
      </UButton>
    </template>
    <template #footer="{ state: formState }">
      <div class="mt-2 rounded border border-dashed border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
        #footer · 当前关键词: {{ (formState as Record<string, unknown>).keyword ?? '—' }}
      </div>
    </template>
  </MSearchForm>
</template>

Async Submit and Validation

loading controls the button loading state. @error returns the list of Zod validation failures:

<script setup lang="ts">
import type { FormErrorEvent, FormSubmitEvent } from '@nuxt/ui'
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }),
  email: afz
    .email({ controlProps: { placeholder: '请输入合法邮箱' } })
    .meta({ label: '邮箱' })
    .optional()
})

type Schema = z.output<typeof schema>

const state = ref<Partial<Schema>>({})
const loading = ref(false)
const toast = useToast()

function onSearch(event: FormSubmitEvent<Schema>) {
  loading.value = true
  setTimeout(() => {
    loading.value = false
    toast.add({ title: '查询完成', description: JSON.stringify(event.data), color: 'success' })
  }, 1500)
}

function onError(event: FormErrorEvent) {
  toast.add({
    title: '校验失败',
    description: `${event.errors?.length ?? 0} 项错误`,
    color: 'error'
  })
}
</script>

<template>
  <MSearchForm
    v-model="state"
    :schema="schema"
    :loading="loading"
    :validate-on="['blur']"
    @submit="onSearch"
    @error="onError"
  />
</template>

API

Props

Prop Default Type
schemaS

Zod 对象 schema,定义表单字段

cols3number | { sm?: number; md?: number; lg?: number; xl?: number; }

网格列数

visibleRows1number

可见行数(折叠时显示的行数)

actions[{ key: 'search', ... }, { key: 'reset', ... }]SearchFormAction[]

动作按钮配置;不传时使用默认 search, reset;传 则关闭所有内置按钮

collapseButtonPropsButtonProps

收起按钮属性

icon'i-lucide-chevron-down'any

展开/收起按钮图标

expandText'展开'string

展开按钮文本

collapseText'收起'string

收起按钮文本

controlsAutoFormControls

自定义控件映射

globalMetaZodAutoFormFieldMeta

全局字段元数据配置

validateOn[]FormInputEvents[]

表单验证时机,详见 UForm 的 validateOn 属性

idstring | number
validate(state: Partial<InferInput<S>>) => FormError<string>[] | Promise<FormError<string>[]>

Custom validation function to validate the form state.

namestring

The name attribute of the form element. For nested forms (nested is true), this is also used as the path of the form's state within its parent form.

validateOnInputDelay`300`number

Delay in milliseconds before validating the form on input events.

transform`true`true

If true, applies schema transformations on submit.

nested`false`false

If true, this form will attach to its parent Form and validate at the same time.

onSubmit(): void | Promise<void> | (event: FormSubmitEvent<InferOutput<S>>): void | Promise<void>
acceptcharsetstring
actionstring
autocompletestring
enctypestring
methodstring
novalidatefalse | true | "true" | "false"
targetstring
modelValue{}Partial<InferInput<S>>
loadingboolean

搜索按钮加载状态(作用于 type==='submit' 或 key==='search' 的按钮)

expandedboolean

受控展开状态;优先级高于 defaultExpanded

defaultExpandedfalseboolean

默认展开状态

loadingAutotrueboolean

是否启用自动 loading 功能。

disabledboolean

Disable all inputs inside the form.

uiRecord<string, ClassNameValue> & { root?: SlotClass; form?: SlotClass; visible?: SlotClass; grid?: SlotClass; header?: SlotClass; footer?: SlotClass; actions?: SlotClass; toggleWrapper?: SlotClass; toggle?: SlotClass; toggleIcon?: SlotClass; collapsed?: SlotClass; }

Emits

Event Type
reset[state: Partial<InferInput<S>>]
clear[state: Partial<InferInput<S>>]
expand[expanded: boolean]
update:expanded[expanded: boolean]
error[event: FormErrorEvent]
update:modelValue[value: Partial<InferInput<S>>]
@submit is forwarded from the underlying UForm and is not listed in the table above. It fires after successful validation and returns a FormSubmitEvent where search conditions are in event.data — the primary outlet for receiving query parameters.

Slots

Slot Type
headerSearchFormSlotProps<S>
footerSearchFormSlotProps<S>
actionsSearchFormSlotProps<S>
extraActionsSearchFormSlotProps<S>
field-label{ label?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-hint{ hint?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-description{ description?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-help{ help?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-error{ error?: string | boolean; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-default{ error?: string | boolean; } & AutoFormFieldContext<Partial<InferInput<S>>, string>

Expose

You can access the typed component instance via useTemplateRef.

NameType
formRefRef<InstanceType<typeof UForm>>

Reference to the UForm component

submit()void

Programmatically trigger form submission (equivalent to clicking the search button)

reset()void

Restore to baseline (the v-model snapshot from initial mount) and fire the reset event

clear()void

Clear all form fields to empty values and fire the clear event

setBaseline(value?)void

Set the restore baseline for reset(); uses the current v-model value if no argument is passed

expandedComputedRef<boolean>

Current expanded/collapsed state

toggle()void

Toggle expand/collapse and emit expand and update:expanded

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    searchForm: {
      slots: {
        root: 'group/search',
        form: 'space-y-4',
        visible: 'relative',
        grid: 'grid gap-4 [grid-template-columns:repeat(var(--m-search-cols,1),minmax(0,1fr))] sm:[grid-template-columns:repeat(var(--m-search-cols-sm,var(--m-search-cols)),minmax(0,1fr))] md:[grid-template-columns:repeat(var(--m-search-cols-md,var(--m-search-cols-sm,var(--m-search-cols))),minmax(0,1fr))] lg:[grid-template-columns:repeat(var(--m-search-cols-lg,var(--m-search-cols-md,var(--m-search-cols-sm,var(--m-search-cols)))),minmax(0,1fr))] xl:[grid-template-columns:repeat(var(--m-search-cols-xl,var(--m-search-cols-lg,var(--m-search-cols-md,var(--m-search-cols-sm,var(--m-search-cols))))),minmax(0,1fr))]',
        header: '',
        footer: '',
        actions: 'flex items-end gap-2 justify-end',
        toggleWrapper: 'flex justify-center pt-2',
        toggle: 'group opacity-75 hover:opacity-100 active:opacity-90 transition-all duration-200',
        toggleIcon: 'size-3.5 transition-all duration-200 opacity-80 group-data-[state=open]:rotate-180 group-data-[state=open]:opacity-100',
        collapsed: 'mt-4'
      }
    }
  }
})

Changelog

No recent changes
Copyright © 2025 - 2026 YiXuan - MIT License