<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>
Usage
import { Form, FormField } from '@hina-ui/vue'
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>
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>
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>
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.
<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>
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>
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>
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
validateOnset tochange, value changes validate again; withblur, only fields that have lost focus show errors. - Errors set through
setErrorstake 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
errorslot 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-describedbyandaria-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.