Use it for
Use BbForm when the user must fix every invalid field before an action runs.
The form controls inside it join it by their name. They check themselves
against string rules or one schema, and @submit fires once all of them pass.
Use something else when
- A plain
<form>— when nothing needs checking in the browser; outside aBbFormthe same controls are plain, andrulesdo nothing useBbFormField— a control you built yourself has to join the form
Also see: Validated forms, for the rules module, field names and paths, when a field checks itself, and the required asterisk.
BbForm needs the vee-validate peer (npm i vee-validate). It renders a bare
<form> and nothing else: spacing, columns and the submit button are yours.
A form, end to end
Give each field a label and a name, add its rules, and handle one
@submit.
<template>
<!--
Each field is a `label` and a `name`: no v-model, no ref per field. The
form holds the values and hands them to `@submit` once every rule passes.
-->
<BbForm
class="flex max-w-md flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-5"
@invalid="status = ''"
@submit="createWorkspace"
>
<header class="flex flex-col gap-1">
<h3 class="m-0 text-base font-semibold">Create your workspace</h3>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
Free for 14 days. No card needed.
</p>
</header>
<BbTextInput
id="form-basic-name"
autocomplete="name"
label="Full name"
name="name"
rules="required"
/>
<BbTextInput
id="form-basic-email"
autocomplete="email"
label="Work email"
name="email"
rules="required|email"
type="email"
/>
<BbTextInput
id="form-basic-password"
autocomplete="new-password"
hint="At least 8 characters."
label="Password"
name="password"
persistent-hint
rules="required|min:8"
type="password"
/>
<BbCheckbox
id="form-basic-terms"
label="I accept the terms of service"
name="terms"
rules="required"
/>
<BbButton type="submit" variant="primary">
Create workspace
</BbButton>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)] empty:hidden" role="status">
{{ status }}
</p>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { email, required } from '@vee-validate/rules';
import { BbButton, BbCheckbox, BbForm, 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.'
);
defineRule('min', (value: unknown, [length]: [number]) =>
String(value ?? '').length >= Number(length) ||
`Use at least ${length} characters.`
);
const status = ref('');
/** Runs only once every field passes; returning the promise locks the form while it runs. */
const createWorkspace = async (values: Record<string, unknown>) => {
status.value = 'Creating your workspace…';
await delay(null, 600);
status.value = `Welcome, ${values.name}. We sent a link to ${values.email}.`;
};
</script>
Press Create workspace with the form empty: every field shows its message, and
focus moves to the first one to fix. Fill it in and the values reach @submit,
keyed by name. Return the request's promise and the form locks until it
settles: the fields turn read-only and the submit button spins, with nothing
to wire (While it saves).
- No
v-modelon the fields. The form holds the values; bind one only where the page owns that value. - No
@submit.prevent. Native submission is always prevented. - Only the library's controls are checked. The
<form>carriesnovalidate, so the browser's bubbles never show. A plain<input>inside it is validated by nobody. - It validates only what you give it. Write
rules="required", or require the key in the schema, to check a field on the client. Therequiredprop on a field joined to the form is a hint: it marks the field and checks nothing, which is the server-validated form. - A control that is not form data (a filter, a "show advanced" switch) takes
formless. - A
<button type="reset">works. It runsresetForm(): every field goes back to the value it was mounted with.
The rule names (required, email, min) are registered once for the whole
app: copy the module from
Validated forms → Define the rules once.
One schema for the whole form
Pass a Zod, Valibot, ArkType or Yup schema to
schema as it is, with no adapter,
and leave rules off the fields.
<template>
<!--
One Zod schema on `schema`: no adapter, no per-field rules. A field's
`name` is its key in the schema, and the cross-field rule reaches the
confirmation field through its `path`.
-->
<BbForm
class="flex max-w-md flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-5"
:schema="changePassword"
@submit="save"
>
<h3 class="m-0 text-base font-semibold">Change password</h3>
<BbTextInput
id="form-schema-current"
autocomplete="current-password"
label="Current password"
name="current"
type="password"
/>
<BbTextInput
id="form-schema-next"
autocomplete="new-password"
hint="At least 12 characters."
label="New password"
name="next"
persistent-hint
type="password"
/>
<BbTextInput
id="form-schema-confirm"
autocomplete="new-password"
label="Repeat new password"
name="confirm"
type="password"
/>
<BbCheckbox
id="form-schema-sign-out"
description="Phones and browsers you are signed in on will ask for the new password."
label="Sign out of other devices"
name="signOutOthers"
/>
<BbButton type="submit" variant="primary">
Update password
</BbButton>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]" role="status">
{{ status }}
</p>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { z } from 'zod/v4';
import { BbButton, BbCheckbox, BbForm, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
const changePassword = z
.object({
// An emptied text field holds `null`: the message goes on the type too.
current: z
.string('Enter your current password.')
.min(1, 'Enter your current password.'),
next: z
.string('Choose a new password.')
.min(12, 'Use at least 12 characters.'),
confirm: z.string('Repeat the new password.'),
// Declared, or Zod drops it from the submitted values.
signOutOthers: z.boolean().default(false),
})
.refine((form) => form.next === form.confirm, {
message: 'The passwords do not match.',
path: ['confirm'],
});
const status = ref('Try two different new passwords first.');
/** Typed from the schema: `values` is its output, defaults applied. */
const save = async (values: z.output<typeof changePassword>) => {
await delay(null, 600);
status.value = values.signOutOthers
? 'Password updated. Your other 2 devices were signed out.'
: 'Password updated.';
};
</script>
Each name is a key of the schema, and the refine puts its message on
confirm through its path. @submit receives the schema's output, typed:
write the handler as (values: z.output<typeof schema>) => ….
- Give the type its message too, as in
z.string('Enter your current password.'). An emptied field holdsnull, and without it the user reads "expected string, received null". - Declare every key you submit. Zod and Valibot drop the others, so an
undeclared checkbox never reaches
@submit. - One source of rules per form. While
schemais set,ruleson the fields are ignored. - A form checked two ways (save as draft, publish) switches a computed
schema:computed(() => (publishing.value ? publish : draft)).
The asterisk follows the schema as it follows rules: see
Validated forms → The required asterisk.
Editing a loaded record
Pass the loaded object as
:model. The fields start from it and
write every change back into it in place, so the rest of the page reads it
live. The form emits no update:modelValue; to react to a change, watch the
object with { deep: true }.
<template>
<div class="grid max-w-2xl gap-6 sm:grid-cols-[minmax(0,1fr)_14rem]">
<!--
`:model` binds the whole object: the fields start from it, write every
change back into it, and the card beside the form reads it live.
-->
<BbForm
id="profile-edit"
v-slot="{ dirty }"
:model="profile"
class="flex flex-col gap-4"
@submit="save"
>
<BbTextInput
id="form-edit-name"
autocomplete="name"
label="Display name"
name="fullName"
rules="required"
/>
<BbTextInput
id="form-edit-title"
label="Job title"
name="jobTitle"
rules="required"
/>
<BbTextarea
id="form-edit-bio"
hint="Up to 160 characters."
label="Bio"
name="bio"
persistent-hint
:rows="3"
rules="max:160"
/>
<footer class="flex flex-wrap items-center gap-2">
<BbButton
:disabled="!dirty"
type="button"
variant="ghost"
@click="form.reset()"
>
Discard
</BbButton>
<BbButton
:disabled="!dirty"
type="submit"
variant="primary"
>
Save profile
</BbButton>
<span class="text-sm text-[color:var(--bb-text-muted)]" role="status">
{{ dirty ? 'Unsaved changes' : status }}
</span>
</footer>
</BbForm>
<aside
aria-label="Profile preview"
class="order-first flex flex-col items-start gap-2 self-start rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4 sm:order-none"
>
<BbAvatar :alt="profile.fullName ?? ''" size="lg" :src="member.avatar" />
<p class="m-0 font-semibold">{{ profile.fullName || 'No name yet' }}</p>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
{{ profile.jobTitle || 'No title' }}
</p>
<p class="m-0 text-sm">{{ profile.bio }}</p>
</aside>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import {
BbAvatar,
BbButton,
BbForm,
BbTextarea,
BbTextInput,
useBbFormContext,
} from 'bitboss-ui';
import { delay, users } from '~/demo-data';
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('max', (value: unknown, [limit]: [number]) =>
String(value ?? '').length <= Number(limit) ||
`Keep it under ${limit} characters.`
);
const member = users[0]!;
// An emptied field writes `null` back into the bound object.
type Profile = {
fullName: string | null;
jobTitle: string | null;
bio: string | null;
};
// As loaded from the server.
const profile = reactive<Profile>({
fullName: member.fullName,
jobTitle: member.jobTitle,
bio: 'Runs product at Vantera. Mostly roadmaps, sometimes spreadsheets.',
});
const form = useBbFormContext('profile-edit');
const status = ref('All changes saved');
/** After the save, the saved values become the new baseline, so `dirty` clears. */
const save = async () => {
await delay(null, 700);
form.snapshot();
status.value = 'Saved just now';
};
</script>
The slot's dirty enables Save profile and Discard. reset() from
useBbFormContext(id) (the slot's resetForm) puts the loaded values back, and
snapshot() after a save makes the saved values the new baseline, so dirty
clears. The object is updated in place, never replaced: with Inertia, bind the
useForm() object and declare every field in it.
- A draft skips
@submit. PostuseBbFormContext(id).values.valuefrom atype="button", then callsnapshot(). Nothing is validated, and the real save still goes through@submit.
Gate Save profile on dirty, not on valid. A form nobody has touched counts as
invalid, so a button disabled until the form is valid looks broken on first
paint.
While it saves
Return the promise from @submit and the form treats the save as running until
it settles. Every field joined to the form turns read-only and the submit
button that submitted spins, so nobody edits a value already on its way or
presses the button twice.
<template>
<!--
@submit returns its promise, so while the save runs the fields are
read-only and the submit button spins. Nothing here binds `loading` or
`readonly`: the form does both, and lifts them when the promise settles.
-->
<BbForm
v-slot="{ isSubmitting }"
class="flex max-w-md flex-col gap-4 rounded-(--bb-radius) border border-(--bb-border) p-5"
:disable-submit-lock="keepEditable"
@submit="logTime"
>
<BbTextInput
id="save-lock-task"
label="Task"
name="task"
rules="required"
/>
<BbNumberInput
id="save-lock-hours"
label="Hours"
:max="24"
:max-precision="2"
:min="0"
name="hours"
rules="required"
/>
<!-- Not form data, so formless: it stays out of @submit and is never locked. -->
<BbSwitch
id="save-lock-editable"
v-model="keepEditable"
formless
label="Keep the fields editable while it saves"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<!-- isSubmitting is still there for anything else you show meanwhile. -->
<p class="m-0 text-sm opacity-70" role="status">
{{ isSubmitting ? 'Saving…' : status }}
</p>
<BbButton type="submit" variant="primary">Log time</BbButton>
</div>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm, BbNumberInput, BbSwitch, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
const keepEditable = ref(false);
const status = ref('');
// A slow request, so the lock is easy to see.
const logTime = async (values: Record<string, unknown>) => {
await delay(null, 2500);
status.value = `Logged ${values.hours} h on “${values.task}”.`;
};
</script>
Press Log time and try to type while it saves. Then turn on the switch and
save again: with
disable-submit-lock
the fields stay editable, and the button still spins until the save settles.
The slot's isSubmitting follows the save either way.
- Read-only, never disabled. The values stay visible and focus stays where
it was; nothing greys out, and assistive tech hears
aria-readonly. When the promise settles each field goes back to its ownreadonly. - Drop a hand-wired
:loading="isSubmitting"on a submit button inside the form: it is redundant.isSubmittingin the slot (submittingonuseBbFormContext) is still there for anything else you show meanwhile, such as the "Saving…" line above. - Only the button that submitted spins. A second submit button, Cancel and
a row action stay as they are. A submit button outside the
<form>that names it withform="…", in a dialog footer, follows the same rule with no:loadingto bind (Driving the form from outside). A submit from code (submit()) spins no submit button: the button whose click calls it spins on its own. One withdisable-auto-loadingnever spins on its own, not even for the save. - The dialog around it locks too. In a
BbDialogor aBbOffCanvas, the surface refuses every close (the ×, Cancel,Escape, the backdrop) until the save settles, so nobody walks away from a request in flight. Withdisable-submit-lockit stays closable. - A synchronous
@submitnever locks. One that throws or rejects still ends the save, so nothing spins forever. - A control of your own reads the lock from its
useBbFormFieldhandle'sreadonly.
Two actions, one form
Enter in a field presses the form's first submit button: that is the HTML rule,
and there is no other way to choose it. So the primary action is the form's
only type="submit" button, and a secondary action ("Save & new") is a plain
button whose click calls the form's submit().
0 saved
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<BbForm id="form-new-task" @submit="save">
<BbTextInput compact label="Task" name="title" rules="required" />
</BbForm>
<!-- Save is the only submit button, so Enter presses it. Save & new is
a plain button: it submits from code, then clears the form if the
save went through. Only the button pressed spins. -->
<div class="flex justify-end gap-2">
<BbButton variant="outline" @click="saveAndNew">Save & new</BbButton>
<BbButton form="form-new-task" type="submit" variant="primary">
Save
</BbButton>
</div>
<p class="m-0 text-sm opacity-70" role="status">
{{ saved.length }} saved{{ saved.length ? `: ${saved.join(', ')}` : '' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm, BbTextInput, useBbFormContext } from 'bitboss-ui';
import { delay } from '~/demo-data';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
const saved = ref<string[]>([]);
const form = useBbFormContext('form-new-task');
// Your API call.
async function save(values: Record<string, unknown>) {
await delay(null, 600);
saved.value.push(String(values.title));
}
// `submit()` resolves true once the save went through.
async function saveAndNew() {
if (await form.submit()) form.reset();
}
</script>
submit()says whether the save went through. It resolvestrueafter a save andfalsewhen the form is invalid or the save failed, soif (await form.submit()) form.reset()clears only a saved form.- The plain button spins while its save runs, because its click returns the promise. Save stays still, and the form locks the same either way.
@submitgets the submitter as its second argument,{ submitter }(nullfor a submit from code). When two submit buttons do share one handler, tell them apart by anameyou put on each:submitter?.getAttribute('name') === 'again'.
Server errors
Hand a 422 to the form in one call, setErrors(errors), from the slot or
useBbFormContext(id): each message lands on the field with that path.
<template>
<BbForm
id="invite-member"
v-slot="{ unmatchedErrors }"
:model="invite"
class="flex max-w-md flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-5"
@submit="sendInvite"
>
<h3 class="m-0 text-base font-semibold">Invite a teammate</h3>
<!-- An error no field owns (here `seats`) lands in `unmatchedErrors`: show it. -->
<BbAlert
v-for="(messages, path) in unmatchedErrors"
:key="path"
:text="messages[0]"
variant="destructive"
/>
<BbTextInput
id="form-server-email"
autocomplete="off"
label="Email"
name="email"
rules="required|email"
type="email"
/>
<BbSelect
id="form-server-role"
:items="roles"
label="Role"
name="role"
rules="required"
/>
<BbButton type="submit" variant="primary">
Send invite
</BbButton>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)] empty:hidden" 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 {
BbAlert,
BbButton,
BbForm,
BbSelect,
BbTextInput,
useBbFormContext,
} from 'bitboss-ui';
import { delay, users } from '~/demo-data';
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('email', (value: unknown) =>
email(value) ? true : 'Enter a valid email address.'
);
// Permission roles only, so all four fit in the open list.
const roles = ['Owner', 'Admin', 'Editor', 'Viewer'];
const invite = reactive({ email: '[email protected]', role: 'Editor' });
const form = useBbFormContext('invite-member');
const status = ref('Send it as it is: the server has something to say.');
/** A stand-in for the API: answers like a Laravel 422, `{ field: string[] }`. */
const post = async (values: Record<string, unknown>) => {
await delay(null, 600);
const member = users.find((user) => user.email === values.email);
if (member) return { email: [`${member.fullName} is already a member.`] };
if (values.role === 'Owner') return { role: ['A workspace has one owner.'] };
if (values.role !== 'Viewer')
return { seats: ['All 10 seats are taken. Invite a Viewer, or add seats.'] };
return null;
};
const sendInvite = async (values: Record<string, unknown>) => {
const errors = await post(values);
if (errors) {
// One call: each message goes on the field with that path.
form.setErrors(errors);
status.value = '';
return;
}
status.value = `Invitation sent to ${values.email}.`;
};
</script>
setErrors takes Laravel's { field: string[] } and Inertia's
{ field: string }, keyed by path. Focus moves to the first field that failed.
A key no field owns, seats here, goes to unmatchedErrors and only there:
render it, or the user never sees it.
Each call replaces the errors of the one before. Merge a message about the whole
request into the same map, under a key no field uses:
setErrors({ ...body.errors, _request: [body.message] }).
When something on the page already holds the errors, such as Inertia's
form.errors or Precognition's, bind them with
:field-errors instead. A message
shows for as long as it is in that object, so its owner clears it, and it never
blocks a submit.
The slot's errors holds every message of a field, string[] per path: read
errors.email?.[0] for the first. errors and unmatchedErrors never share a
path, so { ...errors, ...unmatchedErrors } is one summary of every error.
Also see: Validated forms → Server errors,
for Inertia's useForm, Precognition, Nuxt UI and API Platform.
Lines the user adds and removes
Keep the rows in the bound object, name each field by its index, and key the
v-for by id.
<template>
<!--
The rows are `request.lines`: the form is bound to `request`, so adding
and removing a line is a push and a splice. Each field is named by its
index, which is also how a Laravel 422 answers (`lines.1.qty`).
-->
<BbForm
:model="request"
class="flex max-w-xl flex-col gap-3 sm:rounded-[var(--bb-radius)] sm:border sm:border-[color:var(--bb-border)] sm:p-5"
@invalid="status = ''"
@submit="submit"
>
<h3 class="m-0 text-base font-semibold">Restock request</h3>
<div
aria-hidden="true"
class="hidden gap-2 text-sm font-medium sm:grid sm:grid-cols-[minmax(0,1fr)_12rem_2.25rem]"
>
<span>Product</span><span>Qty</span>
</div>
<!-- On a phone each line stacks: the product, then quantity and remove. -->
<div
v-for="(line, i) in request.lines"
:key="line.id"
class="grid grid-cols-[minmax(0,1fr)_2.25rem] items-start gap-2 border-b border-[color:var(--bb-border)] pb-3 sm:border-0 sm:pb-0 sm:grid-cols-[minmax(0,1fr)_12rem_2.25rem]"
>
<BbSelect
:id="`form-rows-product-${line.id}`"
class="col-span-2 sm:col-span-1"
hide-label
item-text="name"
item-value="sku"
:items="products"
:label="`Product, line ${i + 1}`"
:name="`lines[${i}][sku]`"
placeholder="Pick a product"
rules="required"
/>
<BbNumberInput
:id="`form-rows-qty-${line.id}`"
hide-label
:label="`Quantity, line ${i + 1}`"
:name="`lines[${i}][qty]`"
rules="required|min_value:1"
validation-label="Quantity"
/>
<BbButton
:aria-label="`Remove line ${i + 1}`"
:disabled="request.lines.length === 1"
icon="lucide:trash-2"
type="button"
variant="ghost"
@click="removeLine(i)"
/>
</div>
<div class="flex flex-wrap items-center gap-2 pt-1">
<BbButton prepend:icon="lucide:plus" type="button" variant="outline" @click="addLine">
Add a line
</BbButton>
<BbButton class="ms-auto" type="submit" variant="primary">
Send request
</BbButton>
</div>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)] empty:hidden" role="status">
{{ status }}
</p>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { defineRule } from 'vee-validate';
import { min_value, required } from '@vee-validate/rules';
import { BbButton, BbForm, BbNumberInput, BbSelect } from 'bitboss-ui';
import { delay, products } from '~/demo-data';
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule(
'min_value',
(value: unknown, [min]: [number], ctx: { field: string }) =>
min_value(value, [min]) || `${ctx.field} must be at least ${min}.`
);
type Line = { id: number; sku: string | null; qty: number | null };
let nextId = 3;
const request = reactive({
lines: [
{ id: 1, sku: 'FUR-CHR-1042', qty: 4 },
{ id: 2, sku: null, qty: 1 },
] as Line[],
});
const addLine = () => request.lines.push({ id: nextId++, sku: null, qty: 1 });
const status = ref('');
const removeLine = (index: number) => {
request.lines.splice(index, 1);
status.value = '';
};
const submit = async (values: Record<string, unknown>) => {
const lines = values.lines as Line[];
await delay(null, 600);
const units = lines.reduce((sum, line) => sum + Number(line.qty), 0);
const plural = (count: number, word: string) =>
`${count} ${word}${count === 1 ? '' : 's'}`;
status.value = `Requested ${plural(units, 'unit')} of ${plural(lines.length, 'product')}.`;
};
</script>
Send it with the second line empty, then remove that line: its error goes with
it. Adding a line is a push, removing one is a splice. The form follows the
array, and a row that moves up keeps its own value and error. A Laravel 422
answers with the same path, lines.1.qty.
validation-label
gives a message its noun when the label is not one: "Quantity must be at
least 1", not "Quantity, line 2 must be at least 1".
A form in steps
Give each step its own schema, :schema="steps[step]", and keep what was typed
on the steps you leave with
keep-values-on-unmount.
<template>
<!--
One form, a schema per step. The submit button says "Next" until the last
step, so Enter and the button both go through that step's schema.
`keep-values-on-unmount` keeps what was typed on the steps you left.
-->
<BbForm
:model="signup"
class="flex max-w-md flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-5"
keep-values-on-unmount
:schema="steps[step]"
@invalid="onInvalid"
@submit="onSubmit"
>
<ol class="m-0 flex list-none gap-4 p-0 text-sm text-[color:var(--bb-text-muted)]">
<li
v-for="(title, index) in titles"
:key="title"
:aria-current="index === step ? 'step' : undefined"
:class="{ 'font-semibold text-[color:var(--bb-text)]': index === step }"
>
{{ index + 1 }}. {{ title }}
</li>
</ol>
<h3 ref="heading" class="m-0 text-base font-semibold" tabindex="-1">
{{ headings[step] }}
</h3>
<template v-if="step === 0">
<BbTextInput
id="form-steps-email"
autocomplete="email"
label="Work email"
name="email"
type="email"
/>
<BbTextInput
id="form-steps-password"
autocomplete="new-password"
label="Password"
name="password"
type="password"
/>
</template>
<template v-else-if="step === 1">
<BbTextInput
id="form-steps-company"
autocomplete="organization"
label="Company"
name="company[name]"
/>
<BbTextInput
id="form-steps-vat"
label="VAT number"
name="company[vat]"
placeholder="IT01234567890"
/>
</template>
<template v-else>
<BbNumberInput
id="form-steps-seats"
class="max-w-32"
label="Seats"
:min="1"
name="seats"
/>
<BbCheckbox
id="form-steps-terms"
label="I accept the terms of service"
name="terms"
/>
</template>
<footer class="flex gap-2">
<BbButton v-if="step > 0" type="button" variant="ghost" @click="go(step - 1)">
Back
</BbButton>
<BbButton class="ms-auto" type="submit" variant="primary">
{{ step < steps.length - 1 ? 'Next' : 'Start trial' }}
</BbButton>
</footer>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)] empty:hidden" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, useTemplateRef } from 'vue';
import { z } from 'zod/v4';
import {
BbButton,
BbCheckbox,
BbForm,
type BbFormInvalidPayload,
BbNumberInput,
BbTextInput,
} from 'bitboss-ui';
import { delay } from '~/demo-data';
const vatNumber = /^[A-Z]{2}[0-9A-Z]{8,12}$/;
const account = z.object({
email: z.email('Enter a valid email address.'),
password: z
.string('Choose a password.')
.min(8, 'Use at least 8 characters.'),
});
const company = account.extend({
company: z.object({
name: z
.string('Enter the company name.')
.trim()
.min(1, 'Enter the company name.'),
vat: z
.string('Enter a VAT number.')
.trim()
.toUpperCase()
.regex(vatNumber, 'Enter a VAT number such as IT01234567890.'),
}),
});
// The last step checks the whole signup, so `@submit` gets all of it.
const plan = company.extend({
seats: z
.number('How many seats?')
.int()
.min(1, 'At least 1 seat.')
.max(50, 'Up to 50 seats.'),
terms: z.literal(true, 'Accept the terms to continue.'),
});
const steps = [account, company, plan] as const;
const titles = ['Account', 'Company', 'Plan'];
const headings = ['Create your account', 'Your company', 'Seats and terms'];
const step = ref(0);
const signup = reactive({ seats: 5 });
const status = ref('');
const heading = useTemplateRef<HTMLElement>('heading');
/** Moves to a step and puts focus on its heading. */
const go = async (index: number) => {
step.value = index;
await nextTick();
heading.value?.focus();
};
const onSubmit = async (values: z.output<typeof plan>) => {
if (step.value < steps.length - 1) return go(step.value + 1);
await delay(null, 600);
status.value = `Trial started for ${values.company.name}, ${values.seats} seats.`;
};
/** A failing path on another step: go there, and the form focuses the field. */
const onInvalid = ({ errors }: BbFormInvalidPayload) => {
const first = Object.keys(errors)[0] ?? '';
const owner = first.startsWith('company.') ? 1 : first in account.shape ? 0 : 2;
if (owner !== step.value) step.value = owner;
};
</script>
The submit button reads Next until the last step, so moving on is a submit
the step's schema must pass. The last schema covers the whole signup, and
@submit receives all of it. Keep the steps array as const, or @submit is
typed as the first step.
When the last check fails on a field of an earlier step,
@invalid hands you every failing
path: switch to that step and the form focuses the field once it is on screen.
It fires for server errors too (setErrors, or field-errors bringing a
message) with source: 'server' before the form moves focus, and its
unmatched lists the paths no field claims; a failed submit says
source: 'submit'.
A field that stops applying (a v-if on "business account") keeps its value in
a form like this. Call removeField('vat') when you hide it for good, or its old
value is still submitted and checked.
Driving the form from outside
When the button lives outside the <form> element, in a dialog footer or a page
toolbar, give the form an id. A submit
button names it with the native form attribute: <BbButton form="invite-teammate" type="submit"> submits it through the same @submit and validation, and spins
while the save runs, like a submit button inside it.
The footer button lives outside the form element.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton
v-bb-dialog="{
id: 'invite-teammate-dialog',
onComplete: (email: string) => (status = `Invitation sent to ${email}.`),
}"
variant="primary"
>
Invite teammate
</BbButton>
<BbDialog id="invite-teammate-dialog" title="Invite teammate">
<template #default="{ complete }">
<!-- While the send runs, the dialog refuses every close by itself. -->
<BbForm
id="invite-teammate"
class="flex flex-col gap-4"
@submit="async (values) => complete(await send(values))"
>
<BbTextInput
id="form-dialog-email"
label="Email"
name="email"
rules="required|email"
type="email"
/>
</BbForm>
</template>
<template #footer="{ close }">
<BbButton type="button" variant="ghost" @click="close">
Cancel
</BbButton>
<!-- Outside the <form>: `form` submits it through the same
validation, and it spins while the send runs. -->
<BbButton form="invite-teammate" type="submit" variant="primary">
Send invite
</BbButton>
</template>
</BbDialog>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]" 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,
BbForm,
BbTextInput,
vBbDialog,
} from 'bitboss-ui';
import { delay } from '~/demo-data';
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.'
);
// Your API call: resolves the address the invitation went to.
const send = (values: Record<string, unknown>) => delay(String(values.email), 700);
</script>
The @submit closes the dialog with
complete, which hands the address to the
button that opened it. For anything else, drive the form with
useBbFormContext(id), called once at the top of <script setup>. Its state
is refs (invite.submitting.value); its actions are submit(), reset(),
validate(), setErrors(), snapshot() and removeField().
- The id must be one you set. A form without one has no outside handle, which is right for a self-contained form.
- It is safe before the form mounts. State reads a stable baseline (
false,0,{}) and actions do nothing untilisReadyis true, so a footer rendered ahead of the dialog body is safe. WatchisReadyif you must act on mount. - There is no template-ref API. A ref gives only
$el, the native<form>. Never call$el.submit(): it skips validation and reloads the page.