Skip to content

@vibe-labs/design-vue-forms

SPA-friendly form management for the Vibe Design System. No visual components beyond a renderless <VibeForm> wrapper — just composables, validation, and field bindings that wire into @vibe-labs/design-vue-inputs (or any v-model component).

Installation

ts
import { VibeForm, useVibeForm, required, email } from "@vibe-labs/design-vue-forms";

Peer dependency: vue ^3.5.18. No CSS package required — this is pure logic.

Components

<VibeForm>

Renderless form wrapper. Renders a <form novalidate> element, calls useVibeForm internally, and exposes the full form API via its scoped slot. The @submit event only fires when validation passes.

Quick Start

vue
<script setup lang="ts">
import { VibeForm, required, email } from "@vibe-labs/design-vue-forms";
import { VibeInput } from "@vibe-labs/design-vue-inputs";

const initialValues = { email: "", password: "" };

function validate(values: typeof initialValues) {
  const errors: Record<string, string> = {};
  if (!values.email) errors.email = "Required";
  if (values.password.length < 8) errors.password = "Must be at least 8 characters";
  return errors;
}

async function onSubmit(values: typeof initialValues, { setErrors, setFormError }) {
  try {
    await api.login(values);
    router.push("/dashboard");
  } catch (e) {
    if (e.fieldErrors) setErrors(e.fieldErrors);
    else setFormError("Something went wrong");
  }
}
</script>

<template>
  <VibeForm v-slot="form" :initial-values="initialValues" :validate="validate" @submit="onSubmit">
    <VibeInput v-bind="form.field('email')" label="Email" type="email" />
    <VibeInput v-bind="form.field('password')" label="Password" type="password" />
    <button :disabled="form.submitting.value">Sign in</button>
  </VibeForm>
</template>

Props

PropTypeDefaultDescription
initial-valuesTStarting values. Defines the form shape and TypeScript type.
validate(values: T) => FormErrors<T>Form-level validation. Return errors object — empty = valid. Can be async.
validate-on"change" | "blur" | "submit""submit"When to re-run form-level validation after first submit.
no-form-errorbooleanfalseDisable the built-in form error display.
idstringHTML id for the form element.
autocompletestringHTML autocomplete for the form element.

Events

EventPayloadDescription
submit(values: T, helpers: FormSubmitHelpers<T>)Fires when all validation passes.
resetFires after form.reset() is called.

Slots

SlotScopeDescription
defaultFormReturn<T>The full form API.
form-error{ error: string | null }Override the default form error display.

Scoped Slot API (FormReturn<T>)

Field Binding

vue
<!-- The easy way — spreads modelValue, onUpdate:modelValue, name, error, onBlur -->
<VibeInput v-bind="form.field('email')" label="Email" />

<!-- The explicit way -->
<VibeInput
  v-model="form.values.email"
  :error="form.errors.email"
  name="email"
  @blur="form.touchField('email')"
/>

form.field() is fully typed — autocomplete suggests keys from initialValues.

Reactive State

PropertyTypeDescription
valuesTReactive form values. Mutate directly or via field().
errorsPartial<Record<keyof T, string>>Field-level errors.
formErrorRef<string | null>Non-field error (e.g. network failure).
touchedRecord<keyof T, boolean>Which fields have been blurred.
dirtyComputedRef<boolean>True if any value differs from initial.
submittingRef<boolean>True while onSubmit is executing.
submittedRef<boolean>True after first submit attempt.
submitCountRef<number>Total submit attempts.
validComputedRef<boolean>True when no field errors and no form error.

Methods

MethodDescription
field(name)Returns bindable props for a field.
handleSubmit(e?)Trigger submission programmatically.
setErrors(errors)Set multiple field errors (e.g. from server response).
setFieldError(name, msg)Set a single field error.
clearFieldError(name)Clear a single field error.
clearErrors()Clear all errors.
setFormError(msg)Set the form-level error.
reset(nextValues?)Reset to initial values (or merge partial overrides).
touchField(name)Mark a field as touched.
isFieldDirty(name)Check if a specific field has changed.

Validation

Form-Level (Cross-Field)

ts
function validate(values) {
  const errors = {};
  if (!values.email) errors.email = "Required";
  if (values.password !== values.confirmPassword) {
    errors.confirmPassword = "Passwords don't match";
  }
  return errors;
}

Using Rule Builders

ts
import { createFormValidator, required, email, minLength, matches } from "@vibe-labs/design-vue-forms";

const validate = createFormValidator({
  email: [required(), email()],
  password: [required(), minLength(8)],
  confirmPassword: [required(), matches(() => form.values.password, "Passwords don't match")],
});

Available Rules

RuleDescription
required(msg?)Non-empty after trim.
minLength(n, msg?)Minimum character count.
maxLength(n, msg?)Maximum character count.
pattern(regex, msg?)Must match regex.
email(msg?)Basic email format.
url(msg?)Valid URL (via new URL()).
min(n, msg?)Numeric value ≥ n.
max(n, msg?)Numeric value ≤ n.
matches(getFn, msg?)Must equal another value. Good for confirm fields.
custom(fn, trigger?)Arbitrary validation function.

Per-Field Rules

Rules can also live on the input directly — these work independently of VibeForm:

vue
<VibeInput v-bind="form.field('email')" label="Email" :rules="[required(), email()]" validate-on="blur" />

Server Error Handling

ts
async function onSubmit(values, { setErrors, setFieldError, setFormError }) {
  const res = await fetch("/api/register", { method: "POST", body: JSON.stringify(values) });

  if (!res.ok) {
    const body = await res.json();
    if (body.errors) {
      setErrors(body.errors); // { email: "Already taken", ... }
      return;
    }
    setFormError(body.message ?? "Something went wrong");
  }
}

Composables

useVibeForm(config)

The core composable — use directly when you need form logic without the <VibeForm> component wrapper:

vue
<script setup lang="ts">
import { useVibeForm } from "@vibe-labs/design-vue-forms";

const form = useVibeForm({
  initialValues: { search: "" },
  onSubmit: async (values) => {
    await performSearch(values.search);
  },
});
</script>

<template>
  <form @submit.prevent="form.handleSubmit">
    <VibeInput v-bind="form.field('search')" placeholder="Search..." type="search" />
  </form>
</template>

useFormField(options)

Integration hook for input components to auto-register with a parent VibeForm. Fields auto-register and unregister on unmount.

ts
// Inside a custom input component's setup
import { useFormField } from "@vibe-labs/design-vue-forms";

const formCtx = useFormField({
  name: props.name,
  validate: () => runValidation("submit"),
  clearValidation,
});

Utilities

toFormData(values)

Converts a values object to FormData for multipart submissions. Handles strings, numbers, booleans, File, Blob, and arrays.

ts
import { toFormData } from "@vibe-labs/design-vue-forms";

async function onSubmit(values) {
  await fetch("/api/upload", { method: "POST", body: toFormData(values) });
}

dirtyValues(values, initial)

Returns only the fields that changed — useful for PATCH-style APIs:

ts
import { dirtyValues } from "@vibe-labs/design-vue-forms";

async function onSubmit(values) {
  const changed = dirtyValues(values, initialValues);
  await fetch("/api/profile", { method: "PATCH", body: JSON.stringify(changed) });
}

Form-Error Slot

Override the built-in error display:

vue
<VibeForm v-slot="form" :initial-values="initialValues" @submit="onSubmit">
  <!-- fields -->
  <template #form-error="{ error }">
    <MyCustomAlert v-if="error" :message="error" variant="danger" />
  </template>
</VibeForm>

Dependencies

No runtime dependencies — pure Vue composables. Designed to integrate with @vibe-labs/design-vue-inputs but works with any v-model component.

Build

bash
npm run build

Built with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.


Usage Guide

Setup

ts
import { VibeForm, useVibeForm, required, email, minLength } from "@vibe-labs/design-vue-forms";
// No CSS import needed — this is pure logic

VibeForm

Login form with validation

vue
<script setup lang="ts">
import { VibeForm, required, email, minLength, createFormValidator } from "@vibe-labs/design-vue-forms";
import { VibeInput } from "@vibe-labs/design-vue-inputs";
import { VibeButton } from "@vibe-labs/design-vue-buttons";

const initialValues = { email: "", password: "" };

const validate = createFormValidator({
  email: [required(), email()],
  password: [required(), minLength(8)],
});

async function onSubmit(values: typeof initialValues, { setFormError }) {
  try {
    await api.login(values);
    router.push("/dashboard");
  } catch {
    setFormError("Invalid email or password");
  }
}
</script>

<template>
  <VibeForm v-slot="form" :initial-values="initialValues" :validate="validate" @submit="onSubmit">
    <VibeInput v-bind="form.field('email')" label="Email" type="email" />
    <VibeInput v-bind="form.field('password')" label="Password" type="password" />
    <VibeButton type="submit" full :loading="form.submitting.value">Sign in</VibeButton>
  </VibeForm>
</template>

Registration form with cross-field validation

vue
<script setup lang="ts">
import { VibeForm, required, email, minLength, matches } from "@vibe-labs/design-vue-forms";
import { VibeInput } from "@vibe-labs/design-vue-inputs";
import { VibeButton } from "@vibe-labs/design-vue-buttons";

const initialValues = { email: "", password: "", confirmPassword: "" };

function validate(values: typeof initialValues) {
  const errors: Record<string, string> = {};
  if (!values.email) errors.email = "Required";
  if (!values.password) errors.password = "Required";
  if (values.password.length < 8) errors.password = "At least 8 characters";
  if (values.password !== values.confirmPassword) {
    errors.confirmPassword = "Passwords don't match";
  }
  return errors;
}

async function onSubmit(values: typeof initialValues, { setErrors, setFormError }) {
  const res = await fetch("/api/register", { method: "POST", body: JSON.stringify(values) });
  if (!res.ok) {
    const body = await res.json();
    if (body.errors) setErrors(body.errors);
    else setFormError(body.message ?? "Something went wrong");
  }
}
</script>

<template>
  <VibeForm v-slot="form" :initial-values="initialValues" :validate="validate" validate-on="change" @submit="onSubmit">
    <VibeInput v-bind="form.field('email')" label="Email" type="email" required />
    <VibeInput v-bind="form.field('password')" label="Password" type="password" required />
    <VibeInput v-bind="form.field('confirmPassword')" label="Confirm Password" type="password" required />
    <VibeButton type="submit" full :loading="form.submitting.value" :disabled="!form.valid.value">
      Create Account
    </VibeButton>
  </VibeForm>
</template>

useVibeForm

Composable without the wrapper component

vue
<script setup lang="ts">
import { useVibeForm, required } from "@vibe-labs/design-vue-forms";
import { VibeInput } from "@vibe-labs/design-vue-inputs";

const form = useVibeForm({
  initialValues: { query: "" },
  onSubmit: async (values) => {
    await performSearch(values.query);
  },
});
</script>

<template>
  <form @submit.prevent="form.handleSubmit">
    <VibeInput
      v-bind="form.field('query')"
      type="search"
      placeholder="Search..."
      :rules="[required()]"
    />
    <button type="submit" :disabled="form.submitting.value">Search</button>
  </form>
</template>

Common Patterns

PATCH-only dirty values

vue
<script setup lang="ts">
import { VibeForm, dirtyValues } from "@vibe-labs/design-vue-forms";
import { VibeInput } from "@vibe-labs/design-vue-inputs";

const initialValues = { name: "Jane Doe", bio: "Music lover" };

async function onSubmit(values: typeof initialValues) {
  const changed = dirtyValues(values, initialValues);
  // Only sends changed fields to the API
  await fetch("/api/profile", { method: "PATCH", body: JSON.stringify(changed) });
}
</script>

<template>
  <VibeForm v-slot="form" :initial-values="initialValues" @submit="onSubmit">
    <VibeInput v-bind="form.field('name')" label="Name" />
    <VibeInput v-bind="form.field('bio')" label="Bio" />
    <button type="submit" :disabled="!form.dirty.value">Save changes</button>
  </VibeForm>
</template>

File upload form

vue
<script setup lang="ts">
import { VibeForm, toFormData } from "@vibe-labs/design-vue-forms";

const initialValues = { title: "", file: null as File | null };

async function onSubmit(values: typeof initialValues) {
  await fetch("/api/upload", { method: "POST", body: toFormData(values) });
}
</script>

Vibe