9.7k

TanStack Form

上一页下一页

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

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

演示

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

漏洞报告

报告您遇到的错误,帮助我们改进。

0/100 字符

包括复现步骤、预期行为以及实际发生的情况。

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

方法

此表单利用 TanStack Form 进行强大、无头(headless)的表单处理。我们将使用 <Field /> 组件构建表单,它为您提供了对标记(markup)和样式完全的灵活性

  • 使用 TanStack Form 的 useForm 可组合项进行表单状态管理。
  • form.Field 组件结合渲染属性模式(render prop pattern)以实现受控输入。
  • 用于构建可访问表单的 <Field /> 组件。
  • 使用 Zod 进行客户端验证。
  • 实时验证反馈。

结构说明

这是使用 TanStack Form 和 <Field /> 组件构建表单的基础示例。

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

表单

创建表单架构

我们将从使用 Zod 模式定义表单结构开始。

注意:此示例使用 zod v3 进行模式验证。TanStack Form 通过其验证器 API 与 Zod 及其他标准模式(Standard Schema)验证库无缝集成。

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

设置表单

使用来自 TanStack Form 的 useForm 可组合项,通过 Zod 验证创建您的表单实例。

<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 支持其他验证模式,您可以在文档中阅读相关内容。

构建表单

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

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

完成

就是这样。您现在拥有了一个具备客户端验证功能的完全可访问的表单。

当您提交表单时,onSubmit 函数将接收验证后的表单数据并被调用。如果表单数据无效,TanStack Form 将在每个字段旁边显示错误信息。

验证

客户端验证

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

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

验证模式

TanStack Form 通过 validators 选项支持不同的验证策略

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

显示错误

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

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

使用不同的字段类型

输入框

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

个人资料设置

在下方更新您的个人资料信息。

这是您的公开显示名称。必须介于 3 到 10 个字符之间。只能包含字母、数字和下划线。

<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 组件上使用 field.state.valuefield.handleChange。要显示错误,请将 :aria-invalid 属性添加到 Textarea 组件,并将 :data-invalid 属性添加到 Field 组件中。

个性化

通过告诉我们更多关于您的信息来定制您的体验。

请告诉我们更多关于您的信息。这将用于帮助我们实现您的个性化体验。

<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 组件上使用 field.state.valuefield.handleChange。要显示错误,请将 :aria-invalid 属性添加到 SelectTrigger 组件,并将 :data-invalid 属性添加到 Field 组件中。

语言偏好

选择您的首选口语。

为获得最佳效果,请选择您所说的语言。

<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="(v) => field.handleChange(v as string)"
              >
                <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 组件上使用 field.state.valuefield.handleChange。要显示错误,请将 :aria-invalid 属性添加到 Checkbox 组件,并将 :data-invalid 属性添加到 Field 组件中。对于复选框数组,在 form.Field 组件上使用 mode="array" 以及 TanStack Form 的数组助手。记得在 FieldGroup 组件上添加 data-slot="checkbox-group" 以获得正确的样式和间距。

通知

管理您的通知偏好。

响应

获取耗时请求(如研究或图像生成)的通知。

任务

当您创建的任务有更新时接收通知。

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

单选框组

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

订阅计划

查看每个方案的定价和功能。

方案

您可以随时升级或降级您的方案。

<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="(v) => field.handleChange(v as string)"
              >
                <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。要显示错误,请将 :aria-invalid 属性添加到 Switch 组件,并将 :data-invalid 属性添加到 Field 组件中。

安全设置

管理您的账户安全偏好。

启用多因素身份验证以保护您的账户。

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

复杂表单

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

订阅计划

选择您的订阅方案。

选择您希望多久结算一次。

附加组件

选择您想要包含的附加功能。

高级分析和报告

自动每日备份

24/7 高级客户支持

接收有关您订阅的电子邮件更新

<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="(v) => field.handleChange(v as string)"
              >
                <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="(v) => field.handleChange(v as string)"
              >
                <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: boolean | 'indeterminate') => {
                      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>

重置表单

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

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

数组字段

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

联系邮箱

管理您的联系电子邮件地址。

电子邮件地址

最多添加 5 个我们可以联系您的电子邮件地址。

<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 个电子邮件地址,可以删除单个地址,并且每个地址都独立验证。

使用 FieldArray

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

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

嵌套字段

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

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

添加项

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

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

移除项

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

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

数组验证

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

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