Form

Collects field values, validates them and submits.

Up to 80 characters

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Select, Switch, Text, Textarea } from '@hina-ui/vue'

  const schema = v.object({
    title: v.pipe(
      v.string('Enter a title'),
      v.nonEmpty('Enter a title'),
      v.maxLength(20, 'Keep the title within 20 characters'),
    ),
    category: v.pipe(v.string('Pick a category'), v.nonEmpty('Pick a category')),
    summary: v.pipe(
      v.string('Enter a summary'),
      v.maxLength(80, 'Keep the summary within 80 characters'),
    ),
    published: v.boolean('Choose whether it is public'),
  })

  const categories = [
    { label: 'Game', value: 'game' },
    { label: 'Novel', value: 'novel' },
    { label: 'Manga', value: 'manga' },
  ]

  const values = reactive({
    title: '',
    category: null as string | number | null,
    summary: '',
    published: true,
  })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 800))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="title" label="Title" required>
      <Input v-model="values.title" placeholder="Name of the work" />
    </FormField>
    <FormField name="category" label="Category" required>
      <Select v-model="values.category" :options="categories" />
    </FormField>
    <FormField name="summary" label="Summary" description="Up to 80 characters">
      <Textarea v-model="values.summary" />
    </FormField>
    <FormField name="published">
      <Switch v-model="values.published">Public</Switch>
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Submit</Button>
    <Text v-if="saved" tone="muted" size="sm">Submitted: {{ saved }}</Text>
  </Form>
</template>
vue

Usage

import { Form, FormField } from '@hina-ui/vue'
ts

The form keeps the values of a set of fields, their validation rules and the submit action in one place. values takes a reactive object, and controls bind to its properties with v-model as usual; rules takes the validation rules, which run on submit, and the handler of the submit event is only called once they all pass. Wrap each field in a FormField, which renders the label, the description and the error message and connects the control to the field automatically.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Text } from '@hina-ui/vue'

  const schema = v.object({
    name: v.pipe(v.string('Enter a nickname'), v.nonEmpty('Enter a nickname')),
    email: v.pipe(
      v.string('Enter an email'),
      v.nonEmpty('Enter an email'),
      v.email('The email is not valid'),
    ),
  })

  const values = reactive({ name: '', email: '' })
  const saved = ref('')

  function save(data: unknown) {
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="name" label="Nickname" required>
      <Input v-model="values.name" />
    </FormField>
    <FormField name="email" label="Email" required>
      <Input v-model="values.email" type="email" />
    </FormField>
    <Button type="submit" class="self-start">Submit</Button>
    <Text v-if="saved" tone="muted" size="sm">Submitted: {{ saved }}</Text>
  </Form>
</template>
vue

Examples

Validation rules

rules accepts two forms. One is an object that implements the Standard Schema specification, so schemas from Valibot, Zod, ArkType and similar libraries can be passed as they are; issue paths are joined with . into field names. The other is a function that receives the current values and returns an object keyed by field name with the error text as the value, an empty object when nothing is wrong; it may also return a Promise.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import {
    Button,
    Form,
    FormField,
    Input,
    PasswordInput,
    Text,
    type FormValidator,
  } from '@hina-ui/vue'

  const values = reactive({ username: '', password: '', confirm: '' })
  const saved = ref('')

  const rules: FormValidator = data => {
    const errors: Record<string, string> = {}
    const username = String(data.username ?? '')
    if (!username) errors.username = 'Enter a username'
    else if (!/^[a-z0-9_]{3,16}$/.test(username)) {
      errors.username = '3 to 16 lowercase letters, digits or underscores'
    }
    if (!data.password) errors.password = 'Enter a password'
    if (data.confirm !== data.password) errors.confirm = 'The passwords do not match'
    return errors
  }

  function save() {
    saved.value = values.username
  }
</script>

<template>
  <Form :values="values" :rules="rules" class="w-80" @submit="save">
    <FormField name="username" label="Username" required>
      <Input v-model="values.username" />
    </FormField>
    <FormField name="password" label="Password" required>
      <PasswordInput v-model="values.password" />
    </FormField>
    <FormField name="confirm" label="Confirm password" required>
      <PasswordInput v-model="values.confirm" />
    </FormField>
    <Button type="submit" class="self-start">Sign up</Button>
    <Text v-if="saved" tone="muted" size="sm">Signed up: {{ saved }}</Text>
  </Form>
</template>
vue

When to validate

By default validation runs on submit; after a failed submit every change validates again, so errors clear as soon as they are fixed. With validateOn set to blur, a field starts validating once it loses focus; with change, every change validates.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import {
    Button,
    Form,
    FormField,
    Input,
    SegmentedControl,
    Stack,
    type FormValidateOn,
  } from '@hina-ui/vue'

  const modes = [
    { label: 'On submit', value: 'submit' },
    { label: 'On blur', value: 'blur' },
    { label: 'On change', value: 'change' },
  ]
  const mode = ref<string | number>('submit')

  const schema = v.object({
    name: v.pipe(v.string('Enter a nickname'), v.nonEmpty('Enter a nickname')),
    email: v.pipe(
      v.string('Enter an email'),
      v.nonEmpty('Enter an email'),
      v.email('The email is not valid'),
    ),
  })

  const values = reactive({ name: '', email: '' })
</script>

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <SegmentedControl v-model="mode" :options="modes" aria-label="Validation timing" />
    <Form :key="mode" :values="values" :rules="schema" :validate-on="mode as FormValidateOn">
      <FormField name="name" label="Nickname" required>
        <Input v-model="values.name" />
      </FormField>
      <FormField name="email" label="Email" required>
        <Input v-model="values.email" type="email" />
      </FormField>
      <Button type="submit" class="self-start">Submit</Button>
    </Form>
  </Stack>
</template>
vue

Errors from the server

The server may still reject some fields after a submit. Call setErrors through a template ref with a map from field names to error text, and the errors show under their fields; once the value of that field changes, its error clears on its own. An error without a matching field is available as error on the default slot for you to display.

taken@example.com is rejected by the server

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Text } from '@hina-ui/vue'

  const schema = v.object({
    email: v.pipe(
      v.string('Enter an email'),
      v.nonEmpty('Enter an email'),
      v.email('The email is not valid'),
    ),
  })

  const values = reactive({ email: 'taken@example.com' })
  const form = ref<InstanceType<typeof Form> | null>(null)
  const saved = ref('')

  async function save(data: Record<string, unknown>) {
    await new Promise(resolve => setTimeout(resolve, 600))
    if (data.email === 'taken@example.com') {
      form.value?.setErrors({ email: 'This email is already registered' })
      return
    }
    saved.value = String(data.email)
  }
</script>

<template>
  <Form
    ref="form"
    v-slot="{ submitting }"
    :values="values"
    :rules="schema"
    class="w-80"
    @submit="save"
  >
    <FormField name="email" label="Email" description="taken@example.com is rejected by the server">
      <Input v-model="values.email" type="email" />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Sign up</Button>
    <Text v-if="saved" tone="muted" size="sm">Signed up: {{ saved }}</Text>
  </Form>
</template>
vue

Submitting and disabled

When the submit handler returns a Promise, the form stays in the submitting state until it settles, every field is disabled, and the submitting slot prop drives the loading indicator of the button. disabled disables the whole form.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Stack, Switch } from '@hina-ui/vue'

  const schema = v.object({
    name: v.pipe(v.string('Enter a name'), v.nonEmpty('Enter a name')),
  })

  const values = reactive({ name: 'Hoshimi Shion' })
  const disabled = ref(false)

  function save() {
    return new Promise(resolve => setTimeout(resolve, 1500))
  }
</script>

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <Switch v-model="disabled">Disable the form</Switch>
    <Form
      v-slot="{ submitting }"
      :values="values"
      :rules="schema"
      :disabled="disabled"
      @submit="save"
    >
      <FormField name="name" label="Name">
        <Input v-model="values.name" />
      </FormField>
      <Button type="submit" :loading="submitting" :disabled="disabled" class="self-start">
        Save
      </Button>
    </Form>
  </Stack>
</template>
vue

Reading state outside the form

Besides submit(), validate(), reset() and setErrors(), the template ref exposes the reactive submitting, submitted, invalid, errors and error, with the same meaning as the props of the default slot, readable in the template outside the form.

The save button in the example sits outside the form, binds loading to submitting and calls submit() on click.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Stack } from '@hina-ui/vue'

  const schema = v.object({
    name: v.pipe(v.string('Enter a name'), v.nonEmpty('Enter a name')),
  })

  const values = reactive({ name: 'Hoshimi Shion' })
  const form = ref<InstanceType<typeof Form> | null>(null)

  function save() {
    return new Promise(resolve => setTimeout(resolve, 1500))
  }
</script>

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <Form ref="form" :values="values" :rules="schema" @submit="save">
      <FormField name="name" label="Name">
        <Input v-model="values.name" />
      </FormField>
    </Form>
    <Button :loading="form?.submitting" class="self-start" @click="form?.submit()">Save</Button>
  </Stack>
</template>
vue

Behavior

  • Submitting validates first; on failure the errors show, focus moves to the first invalid control, and the submit handler is not called.
  • Once validation passes the handler is called; when it returns a Promise the form waits for it and stays in the submitting state meanwhile.
  • After a failed submit, or with validateOn set to change, value changes validate again; with blur, only fields that have lost focus show errors.
  • Errors set through setErrors take precedence and clear once the value of their field changes.
  • An issue with an empty path belongs to no field and is exposed as the form-level error slot prop.

Accessibility

  • The root is a native form with the browser’s own validation hints turned off; Enter to submit and the submit button keep their default behavior.
  • While submitting the root carries aria-busy.
  • Error messages are rendered by FormField and linked to the control with aria-describedby and aria-invalid.

API

Props

Prop
Type
Default
Description
values
Record<string, unknown>
—
The reactive object that holds the field values
rules
FormRules
—
Validation rules, a Standard Schema object or a function
validateOn
'submit' | 'blur' | 'change'
'submit'
When validation runs
disabled
boolean
false
Whether the whole form is disabled
class
string
—
Classes appended to the root element

Slots

Slot
Props
Description
default
errors, error, invalid, submitting, submitted
Form content

Events

Event
Payload
Description
submit
values: Record<string, unknown>
Fires once validation passes; a Promise from the handler keeps the form busy

Methods

Method
Description
submit()
Runs a submit
validate()
Validates and returns whether everything passed
setErrors(errors)
Sets external errors keyed by field name
reset()
Clears errors, the blur record and the submit state

State

The template ref also exposes the following reactive state.

Property
Type
Description
submitting
boolean
Whether a submit is in progress
submitted
boolean
Whether a submit has run; false again after reset()
invalid
boolean
Whether there is a field error or a form-level error
errors
FormErrors
The field errors on display, keyed by field name
error
string | undefined
The form-level error that belongs to no field

The FormRules, FormErrors, FormValidator and StandardSchema types are exported from the package entry.