🌐 Nodejs.cn

TanStack 表单

使用 TanStack Form 和 Zod 在 Vue 中构建表单。

本指南探讨了如何使用 TanStack Form 构建表单。你将学习使用 <Field /> 组件创建表单,使用 Zod 实现模式验证,处理错误,并确保可访问性。

🌐 This guide explores how to build forms using TanStack Form. You'll learn to create forms with <Field /> components, implement schema validation with Zod, handle errors, and ensure accessibility.

演示

🌐 Demo

我们将从构建以下表单开始。它有一个简单的文本输入框和一个多行文本框。提交时,我们将验证表单数据并显示任何错误。

🌐 We'll start by building the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.

Bug Report

Help us improve by reporting bugs you encounter.

0/100 characters

Include steps to reproduce, expected behavior, and what actually happened.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from '@/components/ui/input-group'

const formSchema = z.object({
  title: z
    .string()
    .min(5, 'Bug title must be at least 5 characters.')
    .max(32, 'Bug title must be at most 32 characters.'),
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters.')
    .max(100, 'Description must be at most 100 characters.'),
})

const form = useForm({
  defaultValues: {
    title: '',
    description: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h(
        'pre',
        {
          class:
            'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4',
        },
        h('code', JSON.stringify(value, null, 2)),
      ),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Bug Report</CardTitle>
      <CardDescription>
        Help us improve by reporting bugs you encounter.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-demo" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field name="title">
            <template #default="{ field }">
              <Field :data-invalid="isInvalid(field)">
                <FieldLabel :for="field.name">
                  Bug Title
                </FieldLabel>
                <Input
                  :id="field.name"
                  :name="field.name"
                  :model-value="field.state.value"
                  :aria-invalid="isInvalid(field)"
                  placeholder="Login button not working on mobile"
                  autocomplete="off"
                  @blur="field.handleBlur"
                  @input="field.handleChange($event.target.value)"
                />
                <FieldError
                  v-if="isInvalid(field)"
                  :errors="field.state.meta.errors"
                />
              </Field>
            </template>
          </form.Field>

          <form.Field name="description">
            <template #default="{ field }">
              <Field :data-invalid="isInvalid(field)">
                <FieldLabel :for="field.name">
                  Description
                </FieldLabel>
                <InputGroup>
                  <InputGroupTextarea
                    :id="field.name"
                    :name="field.name"
                    :model-value="field.state.value"
                    placeholder="I'm having an issue with the login button on mobile."
                    :rows="6"
                    class="min-h-24 resize-none"
                    :aria-invalid="isInvalid(field)"
                    @blur="field.handleBlur"
                    @input="field.handleChange($event.target.value)"
                  />
                  <InputGroupAddon align="block-end">
                    <InputGroupText class="tabular-nums">
                      {{ field.state.value?.length || 0 }}/100 characters
                    </InputGroupText>
                  </InputGroupAddon>
                </InputGroup>
                <FieldDescription>
                  Include steps to reproduce, expected behavior, and what
                  actually happened.
                </FieldDescription>
                <FieldError
                  v-if="isInvalid(field)"
                  :errors="field.state.meta.errors"
                />
              </Field>
            </template>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-demo">
          Submit
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

方法

🌐 Approach

此表单利用 TanStack Form 提供强大的无头表单处理功能。我们将使用 <Field /> 组件来构建表单,这些组件为你提供了对标记和样式的完全灵活性

🌐 This form leverages TanStack Form for powerful, headless form handling. We'll build our form using the <Field /> components, which give you complete flexibility over the markup and styling.

  • 使用 TanStack Form 的 useForm 组合式函数进行表单状态管理。
  • form.Field 组件,使用渲染属性模式来控制输入。
  • <Field /> 组件用于构建可访问的表单。
  • 使用 Zod 进行客户端验证。
  • 实时验证反馈。

剖析

🌐 Anatomy

这是一个使用 TanStack Form 和 <Field /> 组件的表单的基本示例。

🌐 Here's a basic example of a form using TanStack Form with the <Field /> component.

<template>
  <form
    @submit.prevent="form.handleSubmit"
  >
    <FieldGroup>
      <form.Field
        name="title"
        #default="{ field }"
      >
        <Field :data-invalid="isInvalid(field)">
          <FieldLabel :for="field.name">Bug Title</FieldLabel>
          <Input
            :id="field.name"
            :name="field.name"
            :model-value="field.state.value"
            @blur="field.handleBlur"
            @input="field.handleChange($event.target.value)"
            :aria-invalid="isInvalid(field)"
            placeholder="Login button not working on mobile"
            autocomplete="off"
          />
          <FieldDescription>
            Provide a concise title for your bug report.
          </FieldDescription>
          <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
        </Field>
      </form.Field>
    </FieldGroup>
    <Button type="submit">Submit</Button>
  </form>
</template>

表单

🌐 Form

创建表单模式

🌐 Create a form schema

我们将首先使用 Zod 架构来定义表格的形状。

🌐 We'll start by defining the shape of our form using a Zod schema.

注意: 这个示例使用 zod v3 进行架构验证。TanStack Form 通过其验证器 API 与 Zod 以及其他标准架构验证库无缝集成。

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

const formSchema = z.object({
  title: z
    .string()
    .min(5, 'Bug title must be at least 5 characters.')
    .max(32, 'Bug title must be at most 32 characters.'),
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters.')
    .max(100, 'Description must be at most 100 characters.'),
})
</script>

设置表单

🌐 Setup the form

使用 TanStack Form 的 useForm 可组合函数来创建具有 Zod 验证的表单实例。

🌐 Use the useForm composable from TanStack Form to create your form instance with Zod validation.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

const formSchema = z.object({
  // ...
})

const form = useForm({
  defaultValues: {
    title: '',
    description: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast.success('Form submitted successfully')
  },
})

function isInvalid(field) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <form @submit.prevent="form.handleSubmit">
    <!-- ... -->
  </form>
</template>

我们在这里使用 onSubmit 来验证表单数据。TanStack Form 支持其他验证模式,你可以在文档中了解相关内容。

🌐 We are using onSubmit to validate the form data here. TanStack Form supports other validation modes, which you can read about in the documentation.

建立表单

🌐 Build the form

我们现在可以使用 TanStack Form 的 form.Field 组件和 Field 组件来构建表单。

🌐 We can now build the form using the form.Field component from TanStack Form and the Field components.

::组件源

名称: TanStackForm演示 标题: Form.vue

::

完成

🌐 Done

就是这样。你现在有一个完全可访问的表格,并带有客户端验证。

🌐 That's it. You now have a fully accessible form with client-side validation.

当你提交表单时,onSubmit 函数将使用验证过的表单数据被调用。如果表单数据无效,TanStack Form 会在每个字段旁显示错误信息。

🌐 When you submit the form, the onSubmit function will be called with the validated form data. If the form data is invalid, TanStack Form will display the errors next to each field.

验证

🌐 Validation

客户端验证

🌐 Client-side Validation

TanStack Form 使用 Zod 模式验证你的表单数据。验证会在用户输入时实时发生。

🌐 TanStack Form validates your form data using the Zod schema. Validation happens in real-time as the user types.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'

const formSchema = z.object({
  // ...
})

const form = useForm({
  defaultValues: {
    title: '',
    description: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    console.log(value)
  },
})
</script>

验证模式

🌐 Validation Modes

TanStack 表单通过 validators 选项支持不同的验证策略:

🌐 TanStack Form supports different validation strategies through the validators option:

模式描述
onChange验证在每次更改时触发。
onBlur验证在失去焦点时触发。
onSubmit验证在提交时触发。
<script setup lang="ts">
const form = useForm({
  defaultValues: {
    title: '',
    description: '',
  },
  validators: {
    onSubmit: formSchema,
    onChange: formSchema,
    onBlur: formSchema,
  },
})
</script>

显示错误

🌐 Displaying Errors

使用 FieldError 在字段旁显示错误。用于样式和可访问性:

🌐 Display errors next to the field using FieldError. For styling and accessibility:

  • :data-invalid 属性添加到 Field 组件中。
  • :aria-invalid 属性添加到表单控件中,例如 InputSelectTriggerCheckbox 等。
<script setup lang="ts">
function isInvalid(field) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <form.Field
    name="email"
    #default="{ field }"
  >
    <Field :data-invalid="isInvalid(field)">
      <FieldLabel :for="field.name">Email</FieldLabel>
      <Input
        :id="field.name"
        :name="field.name"
        :model-value="field.state.value"
        @blur="field.handleBlur"
        @input="field.handleChange($event.target.value)"
        type="email"
        :aria-invalid="isInvalid(field)"
      />
      <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
    </Field>
  </form.Field>
</template>

处理不同的字段类型

🌐 Working with Different Field Types

输入

🌐 Input

对于输入字段,在 Input 组件上使用 field.state.valuefield.handleChange。 要显示错误,在 Input 组件上添加 :aria-invalid 属性,在 Field 组件上添加 :data-invalid 属性。

🌐 For input fields, use field.state.value and field.handleChange on the Input component. To show errors, add the :aria-invalid prop to the Input component and the :data-invalid prop to the Field component.

Profile Settings

Update your profile information below.

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'

const formSchema = z.object({
  username: z
    .string()
    .min(3, 'Username must be at least 3 characters.')
    .max(10, 'Username must be at most 10 characters.')
    .regex(
      /^\w+$/,
      'Username can only contain letters, numbers, and underscores.',
    ),
})

const form = useForm({
  defaultValues: {
    username: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Profile Settings</CardTitle>
      <CardDescription>
        Update your profile information below.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-input" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="username">
            <Field :data-invalid="isInvalid(field)">
              <FieldLabel for="form-tanstack-input-username">
                Username
              </FieldLabel>
              <Input
                id="form-tanstack-input-username"
                :name="field.name"
                :model-value="field.state.value"
                :aria-invalid="isInvalid(field)"
                placeholder="shadcn"
                autocomplete="username"
                @blur="field.handleBlur"
                @input="field.handleChange($event.target.value)"
              />
              <FieldDescription>
                This is your public display name. Must be between 3 and 10
                characters. Must only contain letters, numbers, and
                underscores.
              </FieldDescription>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </Field>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-input">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <form.Field
    name="username"
    #default="{ field }"
  >
    <Field :data-invalid="isInvalid(field)">
      <FieldLabel :for="`form-tanstack-input-username`">Username</FieldLabel>
      <Input
        id="form-tanstack-input-username"
        :name="field.name"
        :model-value="field.state.value"
        @blur="field.handleBlur"
        @input="field.handleChange($event.target.value)"
        :aria-invalid="isInvalid(field)"
        placeholder="shadcn"
        autocomplete="username"
      />
      <FieldDescription>
        This is your public display name. Must be between 3 and 10 characters.
        Must only contain letters, numbers, and underscores.
      </FieldDescription>
      <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
    </Field>
  </form.Field>
</template>

文本区域

🌐 Textarea

对于多行文本字段,在 Textarea 组件上使用 field.state.valuefield.handleChange。 要显示错误,在 Textarea 组件上添加 :aria-invalid 属性,在 Field 组件上添加 :data-invalid 属性。

🌐 For textarea fields, use field.state.value and field.handleChange on the Textarea component. To show errors, add the :aria-invalid prop to the Textarea component and the :data-invalid prop to the Field component.

Personalization

Customize your experience by telling us more about yourself.

Tell us more about yourself. This will be used to help us personalize your experience.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'

const formSchema = z.object({
  about: z
    .string()
    .min(10, 'Please provide at least 10 characters.')
    .max(200, 'Please keep it under 200 characters.'),
})

const form = useForm({
  defaultValues: {
    about: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Personalization</CardTitle>
      <CardDescription>
        Customize your experience by telling us more about yourself.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-textarea" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="about">
            <Field :data-invalid="isInvalid(field)">
              <FieldLabel for="form-tanstack-textarea-about">
                More about you
              </FieldLabel>
              <Textarea
                id="form-tanstack-textarea-about"
                :name="field.name"
                :model-value="field.state.value"
                :aria-invalid="isInvalid(field)"
                placeholder="I'm a software engineer..."
                class="min-h-[120px]"
                @blur="field.handleBlur"
                @input="field.handleChange($event.target.value)"
              />
              <FieldDescription>
                Tell us more about yourself. This will be used to help us
                personalize your experience.
              </FieldDescription>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </Field>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-textarea">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <form.Field
    name="about"
    #default="{ field }"
  >
    <Field :data-invalid="isInvalid(field)">
      <FieldLabel :for="`form-tanstack-textarea-about`">
        More about you
      </FieldLabel>
      <Textarea
        id="form-tanstack-textarea-about"
        :name="field.name"
        :model-value="field.state.value"
        @blur="field.handleBlur"
        @input="field.handleChange($event.target.value)"
        :aria-invalid="isInvalid(field)"
        placeholder="I'm a software engineer..."
        class="min-h-[120px]"
      />
      <FieldDescription>
        Tell us more about yourself. This will be used to help us
        personalize your experience.
      </FieldDescription>
      <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
    </Field>
  </form.Field>
</template>

选择

🌐 Select

对于特定组件,在 Select 组件上使用 field.state.valuefield.handleChange。 要显示错误,请在 SelectTrigger 组件上添加 :aria-invalid 属性,并在 Field 组件上添加 :data-invalid 属性。

🌐 For select components, use field.state.value and field.handleChange on the Select component. To show errors, add the :aria-invalid prop to the SelectTrigger component and the :data-invalid prop to the Field component.

Language Preferences

Select your preferred spoken language.

For best results, select the language you speak.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

const spokenLanguages = [
  { label: 'English', value: 'en' },
  { label: 'Spanish', value: 'es' },
  { label: 'French', value: 'fr' },
  { label: 'German', value: 'de' },
  { label: 'Italian', value: 'it' },
  { label: 'Chinese', value: 'zh' },
  { label: 'Japanese', value: 'ja' },
] as const

const formSchema = z.object({
  language: z
    .string()
    .min(1, 'Please select your spoken language.')
    .refine(val => val !== 'auto', {
      message:
        'Auto-detection is not allowed. Please select a specific language.',
    }),
})

const form = useForm({
  defaultValues: {
    language: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-lg">
    <CardHeader>
      <CardTitle>Language Preferences</CardTitle>
      <CardDescription>
        Select your preferred spoken language.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-select" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="language">
            <Field orientation="responsive" :data-invalid="isInvalid(field)">
              <FieldContent>
                <FieldLabel for="form-tanstack-select-language">
                  Spoken Language
                </FieldLabel>
                <FieldDescription>
                  For best results, select the language you speak.
                </FieldDescription>
                <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
              </FieldContent>
              <Select
                :name="field.name"
                :model-value="field.state.value"
                @update:model-value="field.handleChange"
              >
                <SelectTrigger
                  id="form-tanstack-select-language"
                  :aria-invalid="isInvalid(field)"
                  class="min-w-[120px]"
                >
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent position="item-aligned">
                  <SelectItem value="auto">
                    Auto
                  </SelectItem>
                  <SelectSeparator />
                  <SelectItem
                    v-for="language in spokenLanguages"
                    :key="language.value"
                    :value="language.value"
                  >
                    {{ language.label }}
                  </SelectItem>
                </SelectContent>
              </Select>
            </Field>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-select">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <form.Field
    name="language"
    #default="{ field }"
  >
    <Field orientation="responsive" :data-invalid="isInvalid(field)">
      <FieldContent>
        <FieldLabel :for="`form-tanstack-select-language`">
          Spoken Language
        </FieldLabel>
        <FieldDescription>
          For best results, select the language you speak.
        </FieldDescription>
        <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
      </FieldContent>
      <Select
        :name="field.name"
        :model-value="field.state.value"
        @update:model-value="field.handleChange"
      >
        <SelectTrigger
          id="form-tanstack-select-language"
          :aria-invalid="isInvalid(field)"
          class="min-w-[120px]"
        >
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Auto</SelectItem>
          <SelectSeparator />
          <SelectItem
            v-for="language in spokenLanguages"
            :key="language.value"
            :value="language.value"
          >
            {{ language.label }}
          </SelectItem>
        </SelectContent>
      </Select>
    </Field>
  </form.Field>
</template>

复选框

🌐 Checkbox

对于复选框,在 Checkbox 组件上使用 field.state.valuefield.handleChange。 要显示错误,在 Checkbox 组件上添加 :aria-invalid 属性,在 Field 组件上添加 :data-invalid 属性。 对于复选框数组,在 form.Field 组件上使用 mode="array" 并结合 TanStack Form 的数组辅助程序。 记得在 FieldGroup 组件上添加 data-slot="checkbox-group" 以确保正确的样式和间距。

🌐 For checkboxes, use field.state.value and field.handleChange on the Checkbox component. To show errors, add the :aria-invalid prop to the Checkbox component and the :data-invalid prop to the Field component. For checkbox arrays, use mode="array" on the form.Field component and TanStack Form's array helpers. Remember to add data-slot="checkbox-group" to the FieldGroup component for proper styling and spacing.

Notifications

Manage your notification preferences.

Responses

Get notified for requests that take time, like research or image generation.

Tasks

Get notified when tasks you've created have updates.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from '@/components/ui/field'

const tasks = [
  {
    id: 'push',
    label: 'Push notifications',
  },
  {
    id: 'email',
    label: 'Email notifications',
  },
] as const

const formSchema = z.object({
  responses: z.boolean(),
  tasks: z
    .array(z.string())
    .min(1, 'Please select at least one notification type.')
    .refine(
      value => value.every(task => tasks.some(t => t.id === task)),
      {
        message: 'Invalid notification type selected.',
      },
    ),
})

const form = useForm({
  defaultValues: {
    responses: true,
    tasks: [] as string[],
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Notifications</CardTitle>
      <CardDescription>Manage your notification preferences.</CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-checkbox" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="responses">
            <FieldSet>
              <FieldLegend variant="label">
                Responses
              </FieldLegend>
              <FieldDescription>
                Get notified for requests that take time, like research or
                image generation.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field orientation="horizontal" :data-invalid="isInvalid(field)">
                  <Checkbox
                    id="form-tanstack-checkbox-responses"
                    :name="field.name"
                    :model-value="field.state.value"
                    disabled
                    @update:model-value="(checked) => field.handleChange(checked === true)"
                  />
                  <FieldLabel
                    for="form-tanstack-checkbox-responses"
                    class="font-normal"
                  >
                    Push notifications
                  </FieldLabel>
                </Field>
              </FieldGroup>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </FieldSet>
          </form.Field>
          <FieldSeparator />
          <form.Field v-slot="{ field }" name="tasks" mode="array">
            <FieldSet>
              <FieldLegend variant="label">
                Tasks
              </FieldLegend>
              <FieldDescription>
                Get notified when tasks you've created have updates.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field
                  v-for="task in tasks"
                  :key="task.id"
                  orientation="horizontal"
                  :data-invalid="isInvalid(field)"
                >
                  <Checkbox
                    :id="`form-tanstack-checkbox-${task.id}`"
                    :name="field.name"
                    :aria-invalid="isInvalid(field)"
                    :model-value="field.state.value.includes(task.id)"
                    @update:model-value="(checked) => {
                      if (checked) {
                        field.pushValue(task.id)
                      }
                      else {
                        const index = field.state.value.indexOf(task.id)
                        if (index > -1) {
                          field.removeValue(index)
                        }
                      }
                    }"
                  />
                  <FieldLabel
                    :for="`form-tanstack-checkbox-${task.id}`"
                    class="font-normal"
                  >
                    {{ task.label }}
                  </FieldLabel>
                </Field>
              </FieldGroup>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </FieldSet>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-checkbox">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <form.Field
    name="tasks"
    mode="array"
    #default="{ field }"
  >
    <FieldSet>
      <FieldLegend variant="label">Tasks</FieldLegend>
      <FieldDescription>
        Get notified when tasks you've created have updates.
      </FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        <Field
          v-for="task in tasks"
          :key="task.id"
          orientation="horizontal"
          :data-invalid="isInvalid(field)"
        >
          <Checkbox
            :id="`form-tanstack-checkbox-${task.id}`"
            :name="field.name"
            :aria-invalid="isInvalid(field)"
            :model-value="field.state.value.includes(task.id)"
            @update:model-value="(checked | 'indeterminate') => {
              if (checked) {
                field.pushValue(task.id)
              } else {
                const index = field.state.value.indexOf(task.id)
                if (index > -1) {
                  field.removeValue(index)
                }
              }
            }"
          />
          <FieldLabel
            :for="`form-tanstack-checkbox-${task.id}`"
            class="font-normal"
          >
            {{ task.label }}
          </FieldLabel>
        </Field>
      </FieldGroup>
      <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
    </FieldSet>
  </form.Field>
</template>

单选按钮组

🌐 Radio Group

对于单选按钮组,在 RadioGroup 组件上使用 field.state.valuefield.handleChange。 要显示错误,在 RadioGroupItem 组件上添加 :aria-invalid 属性,在 Field 组件上添加 :data-invalid 属性。

🌐 For radio groups, use field.state.value and field.handleChange on the RadioGroup component. To show errors, add the :aria-invalid prop to the RadioGroupItem component and the :data-invalid prop to the Field component.

Subscription Plan

See pricing and features for each plan.

Plan

You can upgrade or downgrade your plan at any time.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from '@/components/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/ui/radio-group'

const plans = [
  {
    id: 'starter',
    title: 'Starter (100K tokens/month)',
    description: 'For individuals and small teams',
  },
  {
    id: 'pro',
    title: 'Pro (1M tokens/month)',
    description: 'For advanced AI usage with more features.',
  },
  {
    id: 'enterprise',
    title: 'Enterprise (Unlimited tokens)',
    description: 'For large teams and heavy usage.',
  },
] as const

const formSchema = z.object({
  plan: z.string().min(1, 'You must select a subscription plan to continue.'),
})

const form = useForm({
  defaultValues: {
    plan: '',
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Subscription Plan</CardTitle>
      <CardDescription>
        See pricing and features for each plan.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-radiogroup" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="plan">
            <FieldSet>
              <FieldLegend>Plan</FieldLegend>
              <FieldDescription>
                You can upgrade or downgrade your plan at any time.
              </FieldDescription>
              <RadioGroup
                :name="field.name"
                :model-value="field.state.value"
                @update:model-value="field.handleChange"
              >
                <FieldLabel
                  v-for="plan in plans"
                  :key="plan.id"
                  :for="`form-tanstack-radiogroup-${plan.id}`"
                >
                  <Field
                    orientation="horizontal"
                    :data-invalid="isInvalid(field)"
                  >
                    <FieldContent>
                      <FieldTitle>{{ plan.title }}</FieldTitle>
                      <FieldDescription>{{ plan.description }}</FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      :id="`form-tanstack-radiogroup-${plan.id}`"
                      :value="plan.id"
                      :aria-invalid="isInvalid(field)"
                    />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </FieldSet>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-radiogroup">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <form.Field
    name="plan"
    #default="{ field }"
  >
    <FieldSet>
      <FieldLegend>Plan</FieldLegend>
      <FieldDescription>
        You can upgrade or downgrade your plan at any time.
      </FieldDescription>
      <RadioGroup
        :name="field.name"
        :model-value="field.state.value"
        @update:model-value="field.handleChange"
      >
        <FieldLabel
          v-for="plan in plans"
          :key="plan.id"
          :for="`form-tanstack-radiogroup-${plan.id}`"
        >
          <Field
            orientation="horizontal"
            :data-invalid="isInvalid(field)"
          >
            <FieldContent>
              <FieldTitle>{{ plan.title }}</FieldTitle>
              <FieldDescription>{{ plan.description }}</FieldDescription>
            </FieldContent>
            <RadioGroupItem
              :value="plan.id"
              :id="`form-tanstack-radiogroup-${plan.id}`"
              :aria-invalid="isInvalid(field)"
            />
          </Field>
        </FieldLabel>
      </RadioGroup>
      <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
    </FieldSet>
  </form.Field>
</template>

切换

🌐 Switch

对于开关,在 Switch 组件上使用 field.state.valuefield.handleChange。 要显示错误,在 Switch 组件上添加 :aria-invalid 属性,并在 Field 组件上添加 :data-invalid 属性。

🌐 For switches, use field.state.value and field.handleChange on the Switch component. To show errors, add the :aria-invalid prop to the Switch component and the :data-invalid prop to the Field component.

Security Settings

Manage your account security preferences.

Enable multi-factor authentication to secure your account.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Switch } from '@/components/ui/switch'

const formSchema = z.object({
  twoFactor: z.boolean().refine(val => val === true, {
    message: 'It is highly recommended to enable two-factor authentication.',
  }),
})

const form = useForm({
  defaultValues: {
    twoFactor: false,
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Security Settings</CardTitle>
      <CardDescription>
        Manage your account security preferences.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-switch" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="twoFactor">
            <Field orientation="horizontal" :data-invalid="isInvalid(field)">
              <FieldContent>
                <FieldLabel :for="field.name">
                  Multi-factor authentication
                </FieldLabel>
                <FieldDescription>
                  Enable multi-factor authentication to secure your account.
                </FieldDescription>
                <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
              </FieldContent>
              <Switch
                :id="field.name"
                :name="field.name"
                :model-value="field.state.value"
                :aria-invalid="isInvalid(field)"
                @update:model-value="field.handleChange"
              />
            </Field>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-switch">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <form.Field
    name="twoFactor"
    #default="{ field }"
  >
    <Field orientation="horizontal" :data-invalid="isInvalid(field)">
      <FieldContent>
        <FieldLabel :for="field.name">
          Multi-factor authentication
        </FieldLabel>
        <FieldDescription>
          Enable multi-factor authentication to secure your account.
        </FieldDescription>
        <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
      </FieldContent>
      <Switch
        :id="field.name"
        :name="field.name"
        :model-value="field.state.value"
        @update:model-value="field.handleChange"
        :aria-invalid="isInvalid(field)"
      />
    </Field>
  </form.Field>
</template>

复杂形式

🌐 Complex Forms

这是一个包含多个字段和验证的更复杂表单的示例。

🌐 Here is an example of a more complex form with multiple fields and validation.

Subscription Plan

Choose your subscription plan.

Choose how often you want to be billed.

Add-ons

Select additional features you'd like to include.

Advanced analytics and reporting

Automated daily backups

24/7 premium customer support

Receive email updates about your subscription

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldTitle,
} from '@/components/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/ui/radio-group'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'

const addons = [
  {
    id: 'analytics',
    title: 'Analytics',
    description: 'Advanced analytics and reporting',
  },
  {
    id: 'backup',
    title: 'Backup',
    description: 'Automated daily backups',
  },
  {
    id: 'support',
    title: 'Priority Support',
    description: '24/7 premium customer support',
  },
] as const

const formSchema = z.object({
  plan: z
    .string({
      required_error: 'Please select a subscription plan',
    })
    .min(1, 'Please select a subscription plan')
    .refine(value => value === 'basic' || value === 'pro', {
      message: 'Invalid plan selection. Please choose Basic or Pro',
    }),
  billingPeriod: z
    .string({
      required_error: 'Please select a billing period',
    })
    .min(1, 'Please select a billing period'),
  addons: z
    .array(z.string())
    .min(1, 'Please select at least one add-on')
    .max(3, 'You can select up to 3 add-ons')
    .refine(
      value => value.every(addon => addons.some(a => a.id === addon)),
      {
        message: 'You selected an invalid add-on',
      },
    ),
  emailNotifications: z.boolean(),
})

const form = useForm({
  defaultValues: {
    plan: 'basic',
    billingPeriod: 'monthly',
    addons: [] as string[],
    emailNotifications: false,
  },
  validators: {
    onSubmit: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>

<template>
  <Card class="w-full max-w-sm">
    <CardContent>
      <form id="subscription-form" @submit.prevent="form.handleSubmit">
        <FieldGroup>
          <form.Field v-slot="{ field }" name="plan">
            <FieldSet>
              <FieldLegend>Subscription Plan</FieldLegend>
              <FieldDescription>
                Choose your subscription plan.
              </FieldDescription>
              <RadioGroup
                :name="field.name"
                :model-value="field.state.value"
                @update:model-value="field.handleChange"
              >
                <FieldLabel for="basic">
                  <Field
                    orientation="horizontal"
                    :data-invalid="isInvalid(field)"
                  >
                    <FieldContent>
                      <FieldTitle>Basic</FieldTitle>
                      <FieldDescription>
                        For individuals and small teams
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      id="basic"
                      value="basic"
                      :aria-invalid="isInvalid(field)"
                    />
                  </Field>
                </FieldLabel>
                <FieldLabel for="pro">
                  <Field
                    orientation="horizontal"
                    :data-invalid="isInvalid(field)"
                  >
                    <FieldContent>
                      <FieldTitle>Pro</FieldTitle>
                      <FieldDescription>
                        For businesses with higher demands
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      id="pro"
                      value="pro"
                      :aria-invalid="isInvalid(field)"
                    />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </FieldSet>
          </form.Field>
          <FieldSeparator />
          <form.Field v-slot="{ field }" name="billingPeriod">
            <Field :data-invalid="isInvalid(field)">
              <FieldLabel :for="field.name">
                Billing Period
              </FieldLabel>
              <Select
                :name="field.name"
                :model-value="field.state.value"
                :aria-invalid="isInvalid(field)"
                @update:model-value="field.handleChange"
              >
                <SelectTrigger :id="field.name">
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="monthly">
                    Monthly
                  </SelectItem>
                  <SelectItem value="yearly">
                    Yearly
                  </SelectItem>
                </SelectContent>
              </Select>
              <FieldDescription>
                Choose how often you want to be billed.
              </FieldDescription>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </Field>
          </form.Field>
          <FieldSeparator />
          <form.Field v-slot="{ field }" name="addons" mode="array">
            <FieldSet>
              <FieldLegend>Add-ons</FieldLegend>
              <FieldDescription>
                Select additional features you'd like to include.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field
                  v-for="addon in addons"
                  :key="addon.id"
                  orientation="horizontal"
                  :data-invalid="isInvalid(field)"
                >
                  <Checkbox
                    :id="addon.id"
                    :name="field.name"
                    :aria-invalid="isInvalid(field)"
                    :checked="field.state.value.includes(addon.id)"
                    @update:checked="(checked) => {
                      if (checked) {
                        field.pushValue(addon.id)
                      }
                      else {
                        const index = field.state.value.indexOf(addon.id)
                        if (index > -1) {
                          field.removeValue(index)
                        }
                      }
                    }"
                  />
                  <FieldContent>
                    <FieldLabel :for="addon.id">
                      {{ addon.title }}
                    </FieldLabel>
                    <FieldDescription>
                      {{ addon.description }}
                    </FieldDescription>
                  </FieldContent>
                </Field>
              </FieldGroup>
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </FieldSet>
          </form.Field>
          <FieldSeparator />
          <form.Field v-slot="{ field }" name="emailNotifications">
            <Field orientation="horizontal" :data-invalid="isInvalid(field)">
              <FieldContent>
                <FieldLabel :for="field.name">
                  Email Notifications
                </FieldLabel>
                <FieldDescription>
                  Receive email updates about your subscription
                </FieldDescription>
              </FieldContent>
              <Switch
                :id="field.name"
                :name="field.name"
                :checked="field.state.value"
                :aria-invalid="isInvalid(field)"
                @update:checked="field.handleChange"
              />
              <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
            </Field>
          </form.Field>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal" class="justify-end">
        <Button type="submit" form="subscription-form">
          Save Preferences
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

重置表单

🌐 Resetting the Form

使用 form.reset() 将表单重置为默认值。

🌐 Use form.reset() to reset the form to its default values.

<template>
  <Button type="button" variant="outline" @click="form.reset()">
    Reset
  </Button>
</template>

数组字段

🌐 Array Fields

TanStack Form 提供了强大的数组字段管理功能,使用 mode="array"。这允许你动态地添加、删除和更新数组项,并且支持完整的验证。

🌐 TanStack Form provides powerful array field management with mode="array". This allows you to dynamically add, remove, and update array items with full validation support.

Contact Emails

Manage your contact email addresses.

Email Addresses

Add up to 5 email addresses where we can contact you.

<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { XIcon } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLegend,
  FieldSet,
} from '@/components/ui/field'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/components/ui/input-group'

const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email('Enter a valid email address.'),
      }),
    )
    .min(1, 'Add at least one email address.')
    .max(5, 'You can add up to 5 email addresses.'),
})

const form = useForm({
  defaultValues: {
    emails: [{ address: '' }],
  },
  validators: {
    onBlur: formSchema,
  },
  onSubmit: async ({ value }) => {
    toast('You submitted the following values:', {
      description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(value, null, 2))),
      position: 'bottom-right',
      class: 'flex flex-col gap-2',
      style: {
        '--border-radius': 'calc(var(--radius)  + 4px)',
      },
    })
  },
})

function isInvalid(field: any) {
  return field.state.meta.isTouched && !field.state.meta.isValid
}

function isSubFieldInvalid(subField: any) {
  return subField.state.meta.isTouched && !subField.state.meta.isValid
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader class="border-b">
      <CardTitle>Contact Emails</CardTitle>
      <CardDescription>Manage your contact email addresses.</CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-tanstack-array" @submit.prevent="form.handleSubmit">
        <form.Field v-slot="{ field }" name="emails" mode="array">
          <FieldSet class="gap-4">
            <FieldLegend variant="label">
              Email Addresses
            </FieldLegend>
            <FieldDescription>
              Add up to 5 email addresses where we can contact you.
            </FieldDescription>
            <FieldGroup class="gap-4">
              <form.Field
                v-for="(_, index) in field.state.value"
                :key="index"
                v-slot="{ field: subField }"
                :name="`emails[${index}].address`"
              >
                <Field
                  orientation="horizontal"
                  :data-invalid="isSubFieldInvalid(subField)"
                >
                  <FieldContent>
                    <InputGroup>
                      <InputGroupInput
                        :id="`form-tanstack-array-email-${index}`"
                        :name="subField.name"
                        :model-value="subField.state.value"
                        :aria-invalid="isSubFieldInvalid(subField)"
                        placeholder="name@example.com"
                        type="email"
                        autocomplete="email"
                        @blur="subField.handleBlur"
                        @input="subField.handleChange"
                      />
                      <InputGroupAddon v-if="field.state.value.length > 1" align="inline-end">
                        <InputGroupButton
                          type="button"
                          variant="ghost"
                          size="icon-xs"
                          :aria-label="`Remove email ${index + 1}`"
                          @click="field.removeValue(index)"
                        >
                          <XIcon />
                        </InputGroupButton>
                      </InputGroupAddon>
                    </InputGroup>
                    <FieldError v-if="isSubFieldInvalid(subField)" :errors="subField.state.meta.errors" />
                  </FieldContent>
                </Field>
              </form.Field>
              <Button
                type="button"
                variant="outline"
                size="sm"
                :disabled="field.state.value.length >= 5"
                @click="field.pushValue({ address: '' })"
              >
                Add Email Address
              </Button>
            </FieldGroup>
            <FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
          </FieldSet>
        </form.Field>
      </form>
    </CardContent>
    <CardFooter class="border-t">
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="form.reset()">
          Reset
        </Button>
        <Button type="submit" form="form-tanstack-array">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

此示例演示了使用数组字段管理多个电子邮件地址。用户最多可以添加5个电子邮件地址,可以删除单个地址,每个地址都会独立验证。

🌐 This example demonstrates managing multiple email addresses with array fields. Users can add up to 5 email addresses, remove individual addresses, and each address is validated independently.

使用 FieldArray

🌐 Using FieldArray

在父字段上使用 mode="array" 以启用数组字段管理。

🌐 Use mode="array" on the parent field to enable array field management.

<template>
  <form.Field
    name="emails"
    mode="array"
    #default="{ field }"
  >
    <FieldSet>
      <FieldLegend variant="label">Email Addresses</FieldLegend>
      <FieldDescription>
        Add up to 5 email addresses where we can contact you.
      </FieldDescription>
      <FieldGroup>
        <template v-for="(_, index) in field.state.value">
          <!-- Nested field for each array item -->
        </template>
      </FieldGroup>
    </FieldSet>
  </form.Field>
</template>

嵌套字段

🌐 Nested Fields

使用括号表示法访问数组中的单个项目:fieldName[index].propertyName。此示例使用 InputGroup 将删除按钮与输入框内联显示。

🌐 Access individual array items using bracket notation: fieldName[index].propertyName. This example uses InputGroup to display the remove button inline with the input.

<template>
  <form.Field
    :name="`emails[${index}].address`"
    #default="{ subField }"
  >
    <Field orientation="horizontal" :data-invalid="isSubFieldInvalid(subField)">
      <FieldContent>
        <InputGroup>
          <InputGroupInput
            :id="`form-tanstack-array-email-${index}`"
            :name="subField.name"
            :model-value="subField.state.value"
            @blur="subField.handleBlur"
            @input="subField.handleChange($event.target.value)"
            :aria-invalid="isSubFieldInvalid(subField)"
            placeholder="name@example.com"
            type="email"
          />
          <InputGroupAddon v-if="field.state.value.length > 1" align="inline-end">
            <InputGroupButton
              type="button"
              variant="ghost"
              size="icon-xs"
              @click="field.removeValue(index)"
              :aria-label="`Remove email ${index + 1}`"
            >
              <XIcon />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
        <FieldError v-if="isSubFieldInvalid(subField)" :errors="subField.state.meta.errors" />
      </FieldContent>
    </Field>
  </form.Field>
</template>

添加项目

🌐 Adding Items

使用 field.pushValue(item) 向数组字段添加项目。当数组达到最大长度时,你可以禁用该按钮。

🌐 Use field.pushValue(item) to add items to an array field. You can disable the button when the array reaches its maximum length.

<template>
  <Button
    type="button"
    variant="outline"
    size="sm"
    @click="field.pushValue({ address: '' })"
    :disabled="field.state.value.length >= 5"
  >
    Add Email Address
  </Button>
</template>

移除条目

🌐 Removing Items

使用 field.removeValue(index) 从数组字段中移除项。你可以有条件地仅在有多于一项时显示移除按钮。

🌐 Use field.removeValue(index) to remove items from an array field. You can conditionally show the remove button only when there's more than one item.

<template>
  <InputGroupButton
    v-if="field.state.value.length > 1"
    @click="field.removeValue(index)"
    :aria-label="`Remove email ${index + 1}`"
  >
    <XIcon />
  </InputGroupButton>
</template>

数组验证

🌐 Array Validation

使用 Zod 的数组方法验证数组字段。

🌐 Validate array fields using Zod's array methods.

<script setup lang="ts">
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email('Enter a valid email address.'),
      })
    )
    .min(1, 'Add at least one email address.')
    .max(5, 'You can add up to 5 email addresses.'),
})
</script>