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.

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>

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

Components

<VibeForm>

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 — full control -->
<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 (on the input)

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");
  }
}

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) });
}

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,
});

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>

Or disable entirely:

vue
<VibeForm v-slot="form" :initial-values="initialValues" no-form-error @submit="onSubmit">
  <div v-if="form.formError.value">{{ form.formError.value }}</div>
</VibeForm>

Package Exports

ts
// Component
export { VibeForm } from "@vibe-labs/design-vue-forms";

// Composables
export { useVibeForm, useFormField } from "@vibe-labs/design-vue-forms";

// Rules
export {
  required,
  minLength,
  maxLength,
  pattern,
  email,
  url,
  min,
  max,
  matches,
  custom,
  createFormValidator,
} from "@vibe-labs/design-vue-forms";

// Utilities
export { toFormData, dirtyValues } from "@vibe-labs/design-vue-forms";

// Injection key
export { VibeFormKey } from "@vibe-labs/design-vue-forms";

// Types
export type {
  FormConfig,
  FormReturn,
  FormErrors,
  FormSubmitHelpers,
  FieldBindings,
  RegisteredField,
  FormContext,
  ValidationRule,
} from "@vibe-labs/design-vue-forms";

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.