Skip to content

Validated forms

Form controls from bitboss-ui/validated, already wired to vee-validate.

On this page

bitboss-ui/validated ships the standard form controls with vee-validate field binding already attached. A validated BbTextInput inside a BbForm needs nothing but a label and its rules.

BbForm documents the host component itself. This page is the setup around it.

A separate entrypoint

Import from bitboss-ui/validated only where you want validation. The core bitboss-ui entrypoint never pulls vee-validate into an app that does not validate anything. That is why the two exist.

bash
npm i vee-validate                             # required peer for /validated
npm i @vee-validate/rules @vee-validate/i18n   # recommended: standard rules and messages

The controls are the same components. BbTextInput from bitboss-ui/validated is BbTextInput plus field binding. Every prop you already know still works, and three more are added.

Define the rules once

Declare your rules with defineRule in a side-effect module, import that module from the entry point, and then reference them as plain strings on every field. Over a whole app this is the setup that costs least.

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 { 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.
defineRule('password', (v: unknown) =>
    typeof v === 'string' && v.length > 0 && v.length < 8
        ? 'Password must be at least 8 characters'
        : true
);

configure({ generateMessage: localize({ en }) });
setLocale('en');
ts
// main.ts — imported once, for its side effects
import './app/validators';

Build the form

Wrap the fields in <BbForm> and give each one a label (or a legend) and its rules. The field name is derived from the label, so there is no vid to write.

vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbButton } from 'bitboss-ui';
import { BbForm, BbTextInput, BbNumberInput, BbCheckbox } from 'bitboss-ui/validated';

const form = reactive({ email: null, quantity: null, terms: false });

// `@submit` hands you the collected values, so the ref has to be able to hold
// them — a bare `ref(null)` infers `Ref<null>` and the assignment will not compile.
const submitted = ref<Record<string, unknown> | null>(null);
</script>

<template>
    <BbForm v-slot="{ isSubmitting }" @submit="(v) => (submitted = v)">
        <BbTextInput v-model="form.email" label="Email" rules="required|email" />
        <BbNumberInput v-model="form.quantity" label="Quantity" rules="required|min_value:1" />
        <BbCheckbox v-model="form.terms" label="Accept terms" rules="required" />
        <BbButton type="submit" :loading="isSubmitting">Submit</BbButton>
    </BbForm>
</template>

@submit fires only when every field passes, with the collected values. The slot also exposes isSubmitting, submitCount, values, validate and resetForm: enough for any chrome that lives inside the form.

Prefer rule strings, but a one-off inline function still works: :rules="(v) => !!v || 'Required'".

Chrome outside the form

A dialog footer, a page toolbar, a leave guard: anything that needs the form's state but does not live inside it. Give the form an explicit id and read it with useBbFormContext(id), the same id-context pattern as useBbTableContext and useBbDropdownContext.

There is no template-ref handle. useBbFormContext is the only outside surface, and that is deliberate. A ref would let a caller reach past the form's own contract into whatever vee-validate happens to expose this version.

The three added props

PropTypeNotes
rulesRuleExpression<modelValue>A rule string ("required|email"), a function, or an object
validateOntiming arrayWhen to re-validate. Per-control defaults below
vidstringEscape hatch — an explicit field name

vid is an escape hatch. The field name comes from the label: "Preferred time" becomes preferredtime. Set it only when that name would not be unique, as in v-for'd rows or array v-models sharing one label.

vue
<BbTextInput
    v-for="(_, i) in form.attendees"
    :key="i"
    v-model="form.attendees[i]"
    label="Attendee"
    :vid="`attendee_${i}`"
/>

BbDropzone has no label, so it always needs one.

External errors compose. Whatever you pass to the base errors prop is merged with the vee-validate messages, so a server error and a rule message show together rather than replacing each other. That is what makes the same field work for client rules and an Inertia or Laravel round trip.

When a field re-validates

DefaultControls
['blur']BbTextInput, BbNumberInput, BbTextarea, BbColorInput, BbDropzone
['inactive']BbSelect, BbTag, BbSlider, BbRating, BbDatePickerInput, BbTimePickerInput, BbCheckboxGroup, BbSwitchGroup, BbRadioGroup
['update:modelValue', 'change', 'blur']BbCheckbox, BbSwitch

The grouping follows how each control is used, not how it is built. A text field is judged when you leave it, a picker when it goes inactive. A single toggle is judged the moment it flips: there is nothing else to wait for.

The required asterisk

Turn on the plugin option and required fields carry a red *:

ts
// vite.config.ts
bitbossUi({ requiredAsterisk: true });

It shows on any /validated control that is required, or whose rules contain a required rule, rendered into the control's #label or #legend slot.

Rules alone are not enough, and deliberately so. Every other rule is conditional on a value being present. max:5 passes on an empty field, which is what makes "optional, but at most 5 if filled" expressible. Raising the asterisk for any rule would mark optional fields as mandatory and contradict the engine.

Both rule shapes are read: the string form ('required|email', arguments allowed, so 'required:true' counts) and the object form ({ required: true }, where { required: false } correctly shows nothing). required_if and its relatives do not count: conditionally required is not required.

Core, non-validated components ignore the option entirely. The marker itself is the exported BbAsterisk, which you can drop into your own label content when you build a bespoke field.

The controls that ship validated: BbTextInput, BbNumberInput, BbTextarea, BbSelect, BbTag, BbCheckbox, BbCheckboxGroup, BbSwitch, BbSwitchGroup, BbRadioGroup, BbRating, BbSlider, BbDatePickerInput, BbTimePickerInput and BbColorInput. There is also BbForm, and the useValidatedField composable for wrapping controls of your own.

Start with one BbForm, one shared validators module and rule strings on the fields. Add useBbFormContext only when controls live outside the form, and use useValidatedField only for a control that the validated entrypoint does not already export. For Inertia submissions, continue with Forms that return promises.