Use it for
Use BbForm when the user must correct every invalid field before an action
runs. Import the form and its fields from bitboss-ui/validated, add string
rules to each field, and handle one submit event after the whole form passes.
Use something else when
- A plain
<form>with controls from the corebitboss-uientrypoint — nothing on the form validates client-side - The same, with
errors— your messages come from a server round trip rather than from client rules, which is what an Inertia or a Laravel form usually wants
It ships from a different entrypoint. bitboss-ui/validated re-exports the
form controls with field binding attached, and it needs the vee-validate peer;
the core bitboss-ui entrypoint never pulls vee-validate into an app that does
not validate anything.
npm i vee-validate
Two things it is not. It is not a layout: it renders a bare <form> and
nothing else, so spacing, columns and section headings stay yours. And it is
not a submit button: put your own <BbButton type="submit"> in the slot.
A validated form, end to end
Register your rules once, then name them on each field. The opening example uses the same string-rule shape you should ship.
<template>
<BbForm
v-slot="{ isSubmitting }"
class="flex max-w-sm flex-col gap-4"
@submit="onSubmit"
>
<BbTextInput
id="form-basic-email"
v-model="invite.email"
label="Work email"
name="email"
required
rules="required|email"
type="email"
/>
<BbButton
class="self-start"
:loading="isSubmitting"
type="submit"
variant="primary"
>
Send invitation
</BbButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { defineRule } from 'vee-validate';
import { email, required } from '@vee-validate/rules';
import { BbButton } from 'bitboss-ui';
import { BbForm, BbTextInput } from 'bitboss-ui/validated';
import { delay } from '~/demo-data';
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('email', (value: unknown) =>
email(value) ? true : 'Enter a valid email address.'
);
const invite = reactive({
email: null as string | null,
});
const status = ref('Submit with the email empty and nothing is sent.');
/** Sends the request after full-form validation passes. */
const onSubmit = async () => {
status.value = 'Sending…';
await delay(null, 600);
status.value = `Invitation sent to ${invite.email}.`;
};
</script>
Submit with an empty email and the request does not start. Correct it and submit
again: the returned request promise keeps isSubmitting true, so the button
stays in its loading state until the work settles.
The validated controls keep the core component API and add rules,
validateOn and vid. The core BbButton stays imported from bitboss-ui.
The full roster of validated controls: BbTextInput, BbNumberInput,
BbTextarea, BbSelect, BbTag, BbCheckbox, BbCheckboxGroup, BbSwitch,
BbSwitchGroup, BbRadioGroup, BbRating, BbSlider, BbDatePickerInput,
BbTimePickerInput, BbColorInput, BbDropzone. Anything else in a BbForm is
along for the ride — see What is actually validated.
Rules, defined once
"required|email" is a reference, not a definition. Register the rules and their
messages once, in a module you import for its side effects, and every field in
the app can name them as a plain string.
npm i vee-validate @vee-validate/rules @vee-validate/i18n
// 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 {
email,
max,
min,
min_value,
numeric,
required,
} from '@vee-validate/rules';
defineRule('required', required);
defineRule('email', email);
defineRule('numeric', numeric);
defineRule('min', min);
defineRule('max', max);
defineRule('min_value', min_value);
// A project rule is the same (value, params, ctx) signature. Return true, or
// the message to show.
defineRule('workEmail', (value: unknown) =>
typeof value === 'string' && value.endsWith('@example.com')
? true
: 'Use your work address.'
);
configure({ generateMessage: localize({ en }) });
setLocale('en');
// main.ts, or a Nuxt plugin — imported once, for its side effects
import '~/validators';
vee-validate's message catalogue is a separate registry from the bitboss-ui
locale option, and the two do not share keys. Register a message locale for
each language you support, set the active one, and if the user can switch
language at runtime call setLocale in step with your own i18n — nothing bridges
them for you.
For a one-off constraint that is not worth a global name, a function works
inline: :rules="(v) => Number(v) % 5 === 0 || 'In blocks of five.'". Prefer the
string form everywhere else; it keeps the rule and its message in one place
instead of scattering both across templates.
Submit is the gate
submit is the only event, and it fires only when every field passes, with
the collected values. An invalid form surfaces its messages and stays where it
is.
Native submission is always prevented, so there is no navigation to suppress:
write @submit, never @submit.prevent. The rendered <form> also carries
novalidate, which is its own section.
The payload is keyed by each field's name, and each field derived its name from
its label: accents are stripped, spaces removed, the rest lowercased.
"Work email" → workemail
"Seats" → seats
"Città di nascita" → cittadinascita
That is worth reading before you wire the payload into an API call, because
workemail is probably not what your endpoint expects. Most forms already keep
their own state through v-model — the example in the first section does — so
the honest answer is usually to ignore the payload and send your own object; the
event is then a signal that validation passed, not a data source. When you do
want the payload to be the request body, name the fields yourself with vid.
vid is the escape hatch for the name, and it is required in two cases. When
labels would collide — rows in a v-for, several fields sharing one label —
every colliding field needs its own:
<BbTextInput
v-for="(_, i) in form.attendees"
:key="i"
v-model="form.attendees[i]"
label="Attendee"
:vid="`attendee_${i}`"
/>
And when there is no label to derive from at all. BbDropzone has none, so it
always needs vid. A validated control with neither a label/legend nor a
vid throws before it renders, naming itself and telling you which of the two to
add — a loud failure, deliberately, because a field with no name silently drops
out of the form.
When validation runs
Each control has a validateOn default chosen for the way it is used, so a text
field does not turn red while someone is still typing into it and a select does
not stay silent after the user has clearly finished.
| Default | Controls |
|---|---|
['blur'] | BbTextInput, BbNumberInput, BbTextarea, BbColorInput, BbDropzone |
['inactive'] | BbSelect, BbTag, BbSlider, BbRating, BbDatePickerInput, BbTimePickerInput, BbCheckboxGroup, BbSwitchGroup, BbRadioGroup |
['update:modelValue', 'change', 'blur'] | BbCheckbox, BbSwitch |
inactive means "once the control has been left" — a picker closed, a group
stepped out of — which is the only moment a multi-part control has finished
saying anything. Override it per field with validate-on when a specific field
needs different timing, and keep the overrides rare: a form where every field
validates on a different schedule feels arbitrary to use.
Two behaviours the table does not show. A field that is already showing an error re-validates as the user types, so the message disappears the moment the value becomes valid rather than at the next blur — the strict rule would leave a red field looking broken while it is being fixed. And submit validates everything, whatever each field's timing says, which is what makes the gate reliable for fields nobody ever touched.
Reading form state in the slot
The default slot is scoped, so chrome that lives inside the form can react without lifting anything into the page.
<template>
<BbForm
v-slot="{ dirty, isSubmitting, submitCount, valid, resetForm }"
class="flex max-w-sm flex-col gap-4"
@submit="onSubmit"
>
<BbTextInput
id="form-state-name"
v-model="profile.name"
label="Display name"
name="name"
required
rules="required"
/>
<BbTextInput
id="form-state-title"
v-model="profile.title"
description="Shown next to your name in every list."
label="Job title"
name="title"
rules="maxLength:40"
/>
<footer class="flex items-center gap-2">
<BbButton
:disabled="!dirty"
type="button"
variant="ghost"
@click="onDiscard(resetForm)"
>
Discard
</BbButton>
<BbButton :loading="isSubmitting" type="submit" variant="primary">
Save changes
</BbButton>
<span class="ms-auto text-sm opacity-70">
{{ dirty ? 'changed' : 'pristine' }} ·
{{ valid ? 'valid' : 'not valid yet' }} · {{ submitCount }} submitted
</span>
</footer>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton } from 'bitboss-ui';
import { BbForm, BbTextInput } from 'bitboss-ui/validated';
import { delay, users } from '~/demo-data';
const member = users[0]!;
const profile = reactive({ name: member.fullName, title: member.jobTitle });
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('maxLength', (value: unknown, [limit]: [number]) =>
String(value ?? '').length <= Number(limit)
? true
: `Keep it under ${limit} characters.`
);
/** Restores initial values and clears validation metadata. */
const onDiscard = (resetForm: () => void) => {
resetForm();
};
const status = ref('Edit a field and the footer follows along.');
/** Returns the request promise so form submission remains pending. */
const onSubmit = async () => {
status.value = 'Saving…';
await delay(null, 700);
status.value = `Saved “${profile.name} — ${profile.title}”.`;
};
</script>
The scope carries values, isSubmitting, submitCount, validate,
resetForm, and vee-validate's meta flags valid, dirty, touched and
pending.
resetForm restores the form values and publishes them back through each
control's v-model, then clears the validation metadata.
submitCount is the one people overlook. On a form long enough that the failing
fields scroll out of view, a single destructiveBbAlert above the fields, shown once
submitCount > 0, tells the user how many problems there are and where to look.
It does not replace the per-field messages — those are where the correction
happens — and there is only ever one of it.
isSubmitting belongs to the form submission lifecycle. It starts before
validation and, when your @submit handler returns a promise, stays true until
that promise settles. Bind it to a submit button's loading.
Request state is a separate ownership decision. If the handler returns the
request promise, isSubmitting is the right pending state. If it dispatches
fire-and-forget work or the request lives in a store, bind loading to that
request's own state instead; BbForm cannot await work you do not return.
Coming from v2
Before 3.0.0-beta.15, isSubmitting ended after validation and
resetForm() did not update bound models. Keep a separate request flag only
while supporting those older builds.
Disabling submit on !valid is a choice, not a default, and usually the wrong
one. A pristine form counts as invalid until its fields have been validated once,
so a disabled-until-valid button reads as broken on first paint: the user has
filled nothing in, so nothing is valid, so the only control that would tell them
what is wrong is switched off. Leave the button enabled and let the failed submit
surface the messages. valid earns its place in wizards and dialogs, where
letting someone advance into a dead-end step is worse.
Driving the form from outside
When the button lives outside the <form> element — a dialog footer, a page
toolbar, a header's "discard changes", a route-leave guard — give the form an
explicit id and read it with useBbFormContext(id).
The footer button lives outside the form element.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="primary" @click="open = true">Invite teammate</BbButton>
<BbDialog v-model="open" title="Invite teammate">
<BbForm
id="invite-teammate"
class="flex flex-col gap-4"
@submit="onSubmit"
>
<BbTextInput
id="form-dialog-email"
v-model="email"
label="Email"
name="email"
required
rules="required|email"
type="email"
/>
</BbForm>
<template #footer>
<BbButton type="button" variant="ghost" @click="open = false">
Cancel
</BbButton>
<!--
The button is outside the <form>, so it cannot be type="submit".
`invite.submit()` routes through the form's own @submit, which
means validation still gates the request exactly once.
-->
<BbButton
:loading="invite.submitting.value"
type="button"
variant="primary"
@click="invite.submit()"
>
Send invite
</BbButton>
</template>
</BbDialog>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { email as emailRule, required } from '@vee-validate/rules';
import { BbButton, BbDialog } from 'bitboss-ui';
import { BbForm, BbTextInput, useBbFormContext } from 'bitboss-ui/validated';
import { delay } from '~/demo-data';
const invite = useBbFormContext('invite-teammate');
const open = ref(false);
const email = ref<string | null>(null);
const status = ref('The footer button lives outside the form element.');
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('email', (value: unknown) =>
emailRule(value) ? true : 'Enter a valid email address.'
);
/** Sends the request through the form's single validated submit path. */
const onSubmit = async () => {
await delay(null, 700);
status.value = `Invitation sent to ${email.value}.`;
open.value = false;
};
</script>
This is the same id-context pattern as useBbTableContext — no template ref, no
shared subtree, no props threaded through a layout. The handle carries isReady,
valid, dirty, touched, pending, submitting, submitCount, values and
errors as computeds, plus submit(), reset() and validate(). Every one of
them is a ref, so read them as invite.dirty.value — the handle is a plain
object and nothing unwraps for you in a template.
submitting mirrors the form's isSubmitting: it covers validation and any
promise returned by @submit. Work owned elsewhere still needs its own request
state.
Keep @submit as the place the work happens. submit() routes through the same
handler the inside button would trigger, so validation gates the request once and
there is one code path rather than two.
Four constraints worth knowing before you build on it. The id must be one you
set — an auto-generated id cannot be targeted, and a form without an id simply
has no outside handle, which is the right default for a self-contained form. The
handle is callable before the form mounts: every field reads a stable
baseline and every action is a no-op until isReady flips, so a footer that
renders ahead of its dialog's content is safe; watch isReady when you need to
tell "not mounted" apart from "mounted and invalid". The registry is populated on
the client only, deliberately, so it cannot leak between server-rendered
requests — a prerendered page sees the baseline. And two mounted forms sharing an
id both write to the same node, so the handle reports whichever wrote last.
There is no template-ref handle, and this is the part people try first.
BbForm exposes nothing but its root element, so form.value.submitForm(),
.isValid, .validate() and .reset() do not exist — and the failure is a
runtime undefined, not a compile error. form.value.$el is a real
HTMLFormElement, and $el.requestSubmit() does dispatch a submit event that
routes through the same handler, so validation still gates it. Use it only for
something genuinely DOM-shaped; useBbFormContext(id).submit() says what you
mean and needs no cast.
Never reach for $el.submit(), the other native method. It bypasses event
handlers entirely, so it skips validation and reloads the page.
requestSubmit() is the one that behaves.
What is actually validated
The rendered <form> carries novalidate. That is not a licence to skip
validation — it hands validation to vee-validate exclusively.
Without it the browser's own constraint validation runs first. A field with
required, type="email", pattern, maxlength or min/max would block the
submit event before BbForm ever saw it, show a native bubble in the browser's
wording and the browser's language, and the form's real messages would never
render. The attributes themselves are worth keeping — type="email" gets the
right mobile keyboard and autofill, required is announced by screen readers —
so the fix is not to drop them. novalidate leaves every attribute in the DOM
and changes only who decides.
The consequence follows directly, and it is the one silent trap on this page: a
field inside <BbForm> that is not a bitboss-ui/validated control is
validated by nobody. vee-validate only knows about registered fields, and the
browser has stepped aside. Nothing warns you; the form just submits with a value
you thought was checked.
import { BbForm, BbTextInput } from 'bitboss-ui/validated';
// The quiet mistake: one control imported from the core entrypoint. Same name,
// same props, no field binding. It renders inside the form, it submits, and no
// rule ever runs against it — there is no `rules` prop to give it.
import { BbSelect } from 'bitboss-ui';
Mixing is legitimate when it is deliberate — a display-only field, a control the
user cannot get wrong — and the rule is simply that every field you expect to be
checked comes from bitboss-ui/validated. For a bespoke control of your own, the
useValidatedField composable is what the library's own wrappers are built on;
it binds your control to the form the same way, so it participates in the gate
instead of sitting beside it.
Marking required fields
A red * beside the label of every required field is one plugin option, applied
project-wide:
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { requiredAsterisk: true },
});
Every bitboss-ui/validated control then renders
BbAsterisk into its own #label or #legend
slot when the field is required, or when its rules contain a literal
required.
Only a literal one. required_if and its relatives do not count — conditionally
required is not required — and a validator function or a zod/yup schema is opaque
to the check, so a schema-driven field that should carry the marker needs
required set explicitly. Rules alone are not enough for the same reason: every
other rule passes on an empty field, which is what makes "optional, at most 280
characters if filled" expressible.
The marker is decoration. It is rendered aria-hidden, so what tells assistive
technology a field is mandatory is the required prop, not the glyph — set both,
and read the BbAsterisk page before you build a
custom label that has to put the marker back by hand.