Skip to content

Validate a group of fields, submit only valid values, and control the form from inside or outside its layout.

import { BbForm } from 'bitboss-ui';

On this page

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 core bitboss-ui entrypoint — 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.

bash
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.

Submit with the email empty and nothing is sent.

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.

bash
npm i vee-validate @vee-validate/rules @vee-validate/i18n
ts
// 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');
ts
// 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.

text
"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:

vue
<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.

DefaultControls
['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.

pristine · valid · 0 submitted

Edit a field and the footer follows along.

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.

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.

ts
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:

ts
// 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.