Skip to content

vee-validate

Wire newt/ui's form controls to vee-validate, with zod for validation.

Install

pnpm dlx --yes vee-validate @vee-validate/zod zod
npx --yes vee-validate @vee-validate/zod zod
yarn dlx --yes vee-validate @vee-validate/zod zod
bunx --yes vee-validate @vee-validate/zod zod
pnpm dlx newtui@latest add form-fields button
npx newtui@latest add form-fields button
yarn dlx newtui@latest add form-fields button
bunx newtui@latest add form-fields button

The schema

import { z } from "zod"

export const channelSchema = z.object({
  name: z
    .string()
    .min(2, "At least two characters.")
    .max(32, "At most 32 characters.")
    .regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers and dashes only."),
  topic: z.string().max(120, "At most 120 characters.").optional(),
  category: z.enum(["text", "voice", "announcement"]),
  nsfw: z.boolean(),
})

The form

useField gives each control its value, its handlers and its error. The controls are ordinary inputs, so v-model binds straight to it.

<script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod"
import { useField, useForm } from "vee-validate"

import { Button } from "@/components/ui/button"
import {
  Checkbox,
  Field,
  FieldHelp,
  Input,
  Label,
  Select,
  Textarea,
} from "@/components/ui/form-fields"
import { channelSchema } from "@/lib/channel-schema"

const { handleSubmit, isSubmitting, resetForm } = useForm({
  validationSchema: toTypedSchema(channelSchema),
  initialValues: { name: "", topic: "", category: "text", nsfw: false },
})

const { value: name, errorMessage: nameError } = useField<string>("name")
const { value: topic, errorMessage: topicError } = useField<string>("topic")
const { value: category } = useField<string>("category")
const { value: nsfw } = useField<boolean>("nsfw")

const onSubmit = handleSubmit(async (values) => {
  await createChannel(values)
  resetForm()
})
</script>

<template>
  <form class="flex flex-col gap-4" @submit="onSubmit">
    <Field :invalid="Boolean(nameError)">
      <Label for="name">Channel name</Label>
      <Input
        id="name"
        v-model="name"
        :aria-invalid="Boolean(nameError)"
        :aria-describedby="nameError ? 'name-error' : undefined"
      />
      <FieldHelp v-if="nameError" id="name-error" variant="error">
        {{ nameError }}
      </FieldHelp>
      <FieldHelp v-else>Lowercase letters, numbers and dashes.</FieldHelp>
    </Field>

    <Field :invalid="Boolean(topicError)">
      <Label for="topic">Topic</Label>
      <Textarea id="topic" v-model="topic" />
      <FieldHelp v-if="topicError" variant="error">{{ topicError }}</FieldHelp>
    </Field>

    <Field>
      <Label for="category">Category</Label>
      <Select id="category" v-model="category">
        <option value="text">Text</option>
        <option value="voice">Voice</option>
        <option value="announcement">Announcement</option>
      </Select>
    </Field>

    <Field>
      <Checkbox id="nsfw" v-model="nsfw" />
      <Label for="nsfw">Age-restricted channel</Label>
    </Field>

    <Button type="submit" variant="primary" :disabled="isSubmitting">
      {{ isSubmitting ? "Creating…" : "Create channel" }}
    </Button>
  </form>
</template>

Validation timing

vee-validate validates on change once a field has been touched, which is usually right. validateOnValueUpdate: false on a useField turns it into submit-only for a field that is expensive to check.

Array fields

<script setup lang="ts">
import { FieldArray } from "vee-validate"
</script>

<template>
  <FieldArray v-slot="{ fields, push, remove }" name="tags">
    <Field v-for="(entry, index) in fields" :key="entry.key">
      <Label>Tag {{ index + 1 }}</Label>
      <Input v-model="entry.value" />
      <Button type="button" variant="secondary" @click="remove(index)">
        Remove
      </Button>
    </Field>
    <Button type="button" variant="secondary" @click="push('')">Add tag</Button>
  </FieldArray>
</template>

entry.key is the key, not the index: removing the first row otherwise remounts every row after it and loses focus.