Wrap the form controls in a BbForm and each one
joins it. Its value is submitted and reset with the form, counted in dirty
and checked against its rules or the form's schema. Outside a BbForm the
same controls are plain, and rules do nothing.
BbForm validates only what you give it. With no schema and no rules
it checks nothing on the client: @submit always fires and the server decides
(Server errors). A control's required is a hint, never a check: it
shows the asterisk and tells assistive technology, and an empty
field still submits.
This page is the setup around the form. The BbForm
page covers the form itself: schemas, edit forms, rows, steps and driving it
from outside.
Install, then wrap the fields
Install the vee-validate peer. An app that renders no BbForm needs none of
it, and a build that uses BbForm without it fails with the install command.
# the peer BbForm needs (>=4.10 <5)
npm i vee-validate
# only for string rules
npm i @vee-validate/rules @vee-validate/i18n
Everything comes from bitboss-ui: BbForm, the controls, useBbFormContext
and useBbFormField. Sixteen controls join a form by themselves:
BbTextInput, BbNumberInput, BbTextarea, BbSelect, BbTag, BbCheckbox,
BbCheckboxGroup, BbSwitch, BbSwitchGroup, BbRadioGroup, BbRating,
BbSlider, BbDatePickerInput, BbTimePickerInput, BbColorInput and
BbDropzone.
- Panels never join:
BbSelectPopover,BbDatePicker,BbTimePicker,BbColorPalette. Use their field twins in a form. BbRadioandBbCalendardo not join either. Validate a choice withBbRadioGroupand a date withBbDatePickerInput, or bind them withuseBbFormField.formlesskeeps a control out: not submitted, not reset, not checked. Use it for a filter or a "show advanced" switch that sits inside the form without joining it.
Define the rules once
Register your rules with defineRule in one module, import it once in the
app's entry file, and name them as strings on every field.
// app/validators.ts
import { configure, defineRule } from 'vee-validate';
import { localize, setLocale } from '@vee-validate/i18n';
import en from '@vee-validate/i18n/dist/locale/en.json';
import {
required,
email,
min_value,
max_value,
numeric,
min,
max,
} from '@vee-validate/rules';
defineRule('required', required);
defineRule('email', email);
defineRule('min_value', min_value);
defineRule('max_value', max_value);
defineRule('numeric', numeric);
defineRule('min', min);
defineRule('max', max);
// Project rules use the same (value, params, ctx) signature. Like the
// built-in rules, an empty value passes: pair it with `required`.
defineRule('password', (v: unknown) => {
if (typeof v !== 'string' || v === '') return true;
return v.length >= 8 || 'Password must be at least 8 characters';
});
configure({ generateMessage: localize({ en }) });
setLocale('en');
// main.ts: imported once, for its side effects
import './app/validators';
A page then writes rules="required|email" and never imports vee-validate.
npx bitboss-ui check flags a string rule the project never registers
(unregistered-rule). For a one-off check that is not worth a name, a function
works inline: :rules="(v) => Number(v) % 5 === 0 || 'In blocks of five.'".
Or one schema for the whole form
If your app already describes its data with Zod, Valibot, ArkType or Yup, pass
that schema to BbForm's schema and write no rules at all.
<script setup lang="ts">
import { z } from 'zod';
import { BbButton, BbForm, BbTextInput } from 'bitboss-ui';
const signup = z.object({
email: z.email('Enter a valid email address.'),
address: z.object({
city: z.string('Enter a city.').trim().min(1, 'Enter a city.'),
}),
});
const save = async (values: z.output<typeof signup>) => { /* … */ };
</script>
<template>
<BbForm :schema="signup" @submit="save">
<BbTextInput label="Email" name="email" type="email" />
<BbTextInput label="City" name="address[city]" />
<BbButton type="submit">Save</BbButton>
</BbForm>
</template>
Any Standard Schema works as it is: no
@vee-validate/zod, nothing else to install. It has been checked against Zod 3.24
and 4, Valibot 1, ArkType 2, Yup 1.7, Joi 18 and Effect Schema 3.
| Library | Message on an emptied text field |
|---|---|
| Zod 4 | z.string('Required').min(1, 'Required') |
| Zod 3 | z.string({ invalid_type_error: 'Required' }) |
| Valibot | v.pipe(v.string('Required'), v.minLength(1, 'Required')) |
| ArkType | type('string').configure({ message: 'Required' }) |
| Yup 1.7+ | .required('Required') already covers it |
| Joi | .required().messages({ '*': 'Required' }) |
| Effect | Schema.String.annotations({ message: () => 'Required' }) |
- Zod 3 also takes
required_error: 'Required'for a key that is missing. - ArkType gives one combined message per field and has no async checks.
- Yup below 1.7 has no Standard Schema: upgrade.
- Joi needs
.prefs({ abortEarly: false })to report every field. It has no async checks, and@submitis untyped. - Effect is a Standard Schema only once wrapped:
:schema="Schema.standardSchemaV1(schema)". It gives one message per field. - Superstruct, TypeBox and runtypes have no Standard Schema. Wrap one in a
vee-validate
TypedSchemayourself, or use a library above.
An emptied BbTextInput or BbTextarea holds null, which is why the table
puts the message on the type. To write z.string().min(1, 'Required') instead,
set the plugin option formControls: { textInputNullValue: 'string', textareaNullValue: 'string' }
and start your text values as ''. A BbNumberInput already holds a number:
use z.number(), not a coercion.
With a schema, set name on every field, since it is the key the schema checks,
and declare every key you submit. Zod and Valibot drop the rest from @submit.
Seed only what you have. An object bound with :model holds the loaded data
and real defaults (a quantity of 1). Leave out what the user must choose, such
as the terms checkbox, so its message shows. Type that object by what you seed,
not as the schema's output: terms: false is not true.
Field names and paths
A field's path is its name. Without one it is the label, slugged
("Preferred time" becomes preferredtime), which is enough for a throwaway
form and wrong for a real one.
Submitted values
Nothing yet. Submit with 0 people first.
<template>
<div class="grid gap-6 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<!--
Each `name` is a path: `company[name]` and `company[seats]` nest under
`company` in the submitted values. `validation-label` names the field
in its messages when the label is a question.
-->
<BbForm
class="flex flex-col gap-4"
@submit="onSubmit"
>
<BbTextInput
id="signup-email"
autocomplete="email"
label="Work email"
name="email"
rules="required|email"
type="email"
/>
<BbTextInput
id="signup-company"
autocomplete="organization"
label="Company"
name="company[name]"
rules="required"
/>
<BbNumberInput
id="signup-seats"
label="How many people will use it?"
name="company[seats]"
rules="required|min_value:1"
validation-label="Seats"
/>
<BbSelect
id="signup-plan"
:items="plans"
label="Plan"
name="plan"
placeholder="Pick a plan"
rules="required"
/>
<BbDatePickerInput
id="signup-start"
label="Start date"
name="start_date"
rules="required"
/>
<BbCheckbox
id="signup-terms"
label="I accept the terms of service"
name="terms"
rules="required"
/>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Start trial
</BbButton>
</BbForm>
<section aria-live="polite" class="grid content-start gap-2 text-sm">
<p class="m-0 font-medium">Submitted values</p>
<pre
v-if="submitted"
class="m-0 overflow-x-auto rounded-[var(--bb-radius)] bg-[var(--bb-muted)] p-3 text-xs whitespace-pre"
>{{ submitted }}</pre>
<p v-else class="m-0 text-[color:var(--bb-text-muted)]">
Nothing yet. Submit with 0 people first.
</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { email, min_value, required } from '@vee-validate/rules';
import {
BbButton,
BbCheckbox,
BbDatePickerInput,
BbForm,
BbNumberInput,
BbSelect,
BbTextInput,
} from 'bitboss-ui';
import { delay } from '~/demo-data';
// In an app these live once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('email', (value: unknown) =>
email(value) ? true : 'Enter a valid email address.'
);
// `ctx.field` is the field's name in messages: its validation-label, else its label.
defineRule(
'min_value',
(value: unknown, [min]: [number], ctx: { field: string }) =>
min_value(value, [min]) || `${ctx.field} must be at least ${min}.`
);
const plans = ['Starter', 'Team', 'Enterprise'];
// `@submit` hands you the collected values, nested by path.
const submitted = ref('');
/**
* The date field submits an ISO string with the reader's UTC offset; print
* only its calendar day, so the output is the same for everyone.
*/
const day = (_key: string, value: unknown) =>
typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)
? value.slice(0, 10)
: value;
/** Returning the promise locks the form and spins the submit button until the request settles. */
const onSubmit = async (values: Record<string, unknown>) => {
await delay(null, 600);
submitted.value = JSON.stringify(values, day, 2);
};
</script>
name is read as a path in either spelling: company[name] and company.name
are the same field, lines[0][qty] is lines.0.qty, and a trailing [] goes
(tags[] is tags). The submitted values nest to match. Set name always
when:
- the label repeats, as in rows of a
v-for::name="`attendees[${i}]`"; - the label is translated or may change. A label-derived path moves with the text, and server errors, drafts and analytics keyed by it stop matching;
- the control has no label. A
BbDropzoneinside a form needs aname, or it throws; - the same group renders twice, such as billing and shipping addresses.
Give your group component a
prefixprop and name its fields:name="`${prefix}[street]`": the values nest asbilling.streetandshipping.street.
name also goes on the native input, so the same field posts under it in a
plain HTML form. For a native post to PHP or Laravel, use brackets:
address.city arrives as address_city, address[city] as a nested array.
Messages name a field by its label, never by its path. Set
validation-label
when the label is not the noun a sentence needs: above, "How many people will
use it?" reads as "Seats must be at least 1". A field with neither says "This
field".
Coming from v2vid → name / validation-label
v2's vid named the field in messages: that is validation-label now. Where
vid set the field's key, write name with the same value. A leftover vid
has no effect at runtime and a development build warns; npx bitboss-ui check --fix rewrites each one by its value.
When a field checks itself
Every control follows one
validation-mode, and the
default, balanced, suits most forms. Pick a mode and try it on the fields
below.
<template>
<div class="grid gap-6 md:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<BbRadioGroup
id="modes-mode"
v-model="mode"
:item-props="{ description: 'description' }"
item-text="value"
:items="modes"
legend="Validation mode"
list-layout="vertical"
name="mode"
/>
<!-- Re-keyed per mode, so every switch starts from a fresh form. -->
<BbForm
:key="mode"
class="flex flex-col gap-4 self-start"
:validation-mode="mode"
@submit="onSubmit"
>
<BbTextInput
id="modes-handle"
autocomplete="username"
label="Username"
name="handle"
rules="required|min:3"
/>
<BbTextInput
id="modes-email"
autocomplete="email"
label="Email"
name="email"
rules="required|email"
type="email"
/>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Save
</BbButton>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]" role="status">{{ status }}</p>
</BbForm>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { defineRule } from 'vee-validate';
import { email, required } from '@vee-validate/rules';
import {
BbButton,
BbForm,
BbRadioGroup,
BbTextInput,
type ValidationMode,
} from 'bitboss-ui';
import { delay } from '~/demo-data';
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('email', (value: unknown) =>
email(value) ? true : 'Enter a valid email address.'
);
defineRule('min', (value: unknown, [length]: [number]) =>
String(value ?? '').length >= Number(length) ||
`Use at least ${length} characters.`
);
type Mode = { value: ValidationMode; description: string; tryIt: string };
const modes: Mode[] = [
{
value: 'balanced',
description: 'On leaving the field, then live while it is wrong. The default.',
tryIt: 'Type one letter in Username, then leave the field.',
},
{
value: 'aggressive',
description: 'On every keystroke.',
tryIt: 'Start typing an email: it is checked as you type.',
},
{
value: 'lazy',
description: 'Only on leaving the field.',
tryIt: 'Leave Username short, then come back and fix it.',
},
{
value: 'submit-only',
description: 'Only on submit.',
tryIt: 'Leave both fields empty: nothing happens until Save.',
},
{
value: 'submit-live',
description: 'Checked on submit, then live while it shows an error.',
tryIt: 'Press Save with the fields empty, fix one, then break it again.',
},
];
const mode = ref<ValidationMode>('balanced');
const tryIt = () => modes.find((entry) => entry.value === mode.value)?.tryIt ?? '';
const status = ref(tryIt());
watch(mode, () => (status.value = tryIt()));
const onSubmit = async () => {
await delay(null, 500);
status.value = 'Saved.';
};
</script>
| Mode | First check | While it shows an error | Once fixed |
|---|---|---|---|
balanced | leave | every change | waits for leave |
aggressive | every change | every change | every change |
lazy | leave | leave only | leave |
submit-only | submit | the next submit | the next submit |
submit-live | submit | every change | quiet until the next submit |
Leaving the field means blur for a text-like control, and focus leaving the
whole control for a picker or a group. A checkbox or switch click counts as the
change and the leave at once. Whatever the mode, a failed submit checks every
field. When the page writes a value itself, a field that shows an error is
checked again, except in lazy and submit-only.
Set the mode on one field, on the whole BbForm
(validation-mode="submit-live"), or app-wide with the plugin option
formControls: { validationMode: 'lazy' }. The field's own wins, then the
form's.
balancedfor most forms.submit-onlyorsubmit-livefor long forms that should not nag while they are being filled in.lazyfor expensive async checks, such as "is this username free".aggressiveonly on purpose: it flags half-typed values.
Server errors
Two ways to hand a server's validation errors to the form. Both take paths as
field names, with dots or brackets.
:field-errors="errors"when something on the page already holds them: Inertia'sform.errors, Precognition's, arefyou fill from a 422. A message shows while it is in the object, so the provider clears it, not the form. It never blocks a submit.setErrors(errors)for a one-off answer. Call it onuseBbFormContext(id)or from theBbFormslot. It takes{ path: string | string[] }, and the form drops a message when the user edits its field.
Each message lands on its field, and focus moves to the first one that failed.
A key no field owns goes to unmatchedErrors: render it yourself.
| Source | Its errors | Into the form |
|---|---|---|
| Inertia visit | $page.props.errors, { key: string } | :field-errors="$page.props.errors" |
| Inertia, error bag | $page.props.errors[bag] | :field-errors="$page.props.errors.<bag>" |
Inertia useForm | inertiaForm.errors | :field-errors="inertiaForm.errors" |
| Laravel 422 | body.errors, { key: string[] } | errors.value = body.errors, then :field-errors="errors" |
| Precognition | precognitionForm.errors | :field-errors="precognitionForm.errors" |
| vee-validate | errorBag, { key: string[] } | form.setErrors(errorBag.value) |
Nuxt UI UForm | FormError[] | form.setErrors(byPath(list)) |
| API Platform | body.violations | byPath, see below |
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { BbButton, BbForm, BbTextInput } from 'bitboss-ui';
const form = useForm({ email: '', address: { city: '' } });
</script>
<template>
<BbForm :model="form" :field-errors="form.errors" @submit="form.post('/signup')">
<BbTextInput label="Email" name="email" type="email" />
<BbTextInput label="City" name="address.city" />
<BbButton type="submit">Create account</BbButton>
</BbForm>
</template>
// fetch or axios, a Laravel 422: keep the answer in a ref
const errors = ref<Record<string, string[]> | null>(null);
async function save(values: Record<string, unknown>) {
errors.value = null;
const response = await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(values),
});
if (response.status === 422) errors.value = (await response.json()).errors;
}
// Nuxt UI FormError[] and API Platform violations: group by path
const byPath = (list: { name?: string; message: string }[]) =>
list.reduce<Record<string, string[]>>((map, { name, message }) => {
(map[name || '_request'] ??= []).push(message);
return map;
}, {});
form.setErrors(byPath(formErrors));
form.setErrors(
byPath(
body.violations.map((v) => ({
name: v.propertyPath,
message: v.message,
})),
),
);
- A message about the whole request (a 403, a 500 text) has no path. Put it
in the same map under a key no field uses, since each
setErrorscall replaces the last:form.setErrors({ ...body.errors, _request: [body.message] }). - One summary of every error is
{ ...errors, ...unmatchedErrors }: the two never share a path.errors.emailis an array; readerrors.email?.[0].
A worked example is in BbForm → Server errors, and the Inertia side in Inertia and Laravel.
For one field's message from elsewhere, the control's own errors prop still
works: it is merged with the rule messages, not replaced by them.
The required asterisk
Required fields carry a red * after the label, out of the box. The plugin
option requiredAsterisk (default true) turns it off app-wide:
// vite.config.ts: only to hide the marker everywhere
bitbossUi({ requiredAsterisk: false });
<template>
<!--
`required` is enough: `requiredAsterisk` is on by default, so the field
draws the marker itself. Write `<BbAsterisk />` only in a label you build.
-->
<div class="flex max-w-md flex-col gap-4">
<BbTextInput
id="marker-email"
autocomplete="email"
label="Work email"
name="email"
required
type="email"
/>
<BbTextInput
id="marker-phone"
autocomplete="tel"
hint="Optional. Only for delivery questions."
label="Phone"
name="phone"
persistent-hint
type="tel"
/>
</div>
</template>
<script setup lang="ts">
import { BbTextInput } from 'bitboss-ui';
</script>
Inside a BbForm it shows on a control whose rules contain a required
rule, in the string form ('required|email', 'required:true') or the object
form ({ required: true }), or whose key the schema requires. Outside a form it
follows the required prop.
With a schema, the schema decides. A key gets the asterisk when it is not
optional, has no default and is not nullable, and its parent object is required
too. The marker follows a schema that changes per step.
Inside a BbForm, the required prop is a hint. It shows the asterisk and
tells assistive technology the field is required, and checks nothing: the form
posts with novalidate and vee-validate never reads the attribute, so an empty
field submits and the server answers. That is the server-validated form:
<BbForm :model="draft" :field-errors="form.errors" @submit="save">
<!-- the server checks it; the asterisk says so -->
<BbTextInput name="email" label="Email" required />
</BbForm>
To stop an empty submit on the client too, write the requirement where the form validates, and the asterisk follows:
<BbTextInput name="email" label="Email" rules="required|email" />
<!-- a condition goes in the rules too -->
<BbTextInput name="vat" label="VAT number" :rules="{ required: isCompany }" />
Only required raises the marker, on purpose. Every other rule passes on an
empty field, which is what makes "optional, but at most 280 characters if
filled" expressible. required_if and its relatives do not count either.
A validator function is opaque to the marker: nothing can read what it does
with an empty value. Add the required hint beside it to mark the field, or
make the requirement a client check: register the function as a named rule and
list it after required (rules="required|vat"), or require the key in the
schema.
The marker is BbAsterisk, rendered
aria-hidden; drop it into a label you build yourself. Inside a form, a field
its rules or schema require gets aria-required by itself, so assistive
technology announces it.
A control of your own
useBbFormField(formId, { name, rules }) makes a control you built a field of
the form: submitted, reset, counted in dirty and focused on a failed submit.
Name the workspace and press Save appearance without a colour: focus lands
on the swatches.
Bind controlAttrs once, on the element that is the field (here the swatch
group), and errorAttrs on the message. A custom field follows the form's
validation-mode like any other. Assigning value is the change;
call handleBlur() when the user is done with the control (here, focus leaving
the group) and that is the leave. Without it the field is first checked at
submit, and lazy waits for it. Do not call validate() on every change: that
is aggressive in any form.
- With a
schemaon the form, the field'srulesare ignored: put its key in the schema,accent: z.string('Pick an accent colour.'). - Wrapping a library control instead? Its three form props are typed as
FormFieldProps, and the mode asValidationMode, both frombitboss-ui.
For Inertia submissions, continue with Forms that return promises.