Skip to content

attaform/Attaform

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

315 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ™ŒπŸ½ Attaform

npm version npm downloads License Node.js Test Suite Nuxt

A type-safe, schema-driven form library for Vue 3 and Nuxt with first-class Zod support.

Installation

npm install attaform zod

That's it for client-side rendering. Forms render and validate the moment you call useForm β€” the registry self-installs on first use.

Going further

Nuxt 3 / 4 β€” add the module:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['attaform/nuxt'],
})

Bare Vue + SSR β€” add the Vite plugin so server-rendered HTML matches the hydrated client (the plugin injects :value / :checked bindings at compile time):

// vite.config.ts
import vue from '@vitejs/plugin-vue'
import { attaform } from 'attaform/vite'

export default defineConfig({
  plugins: [vue(), attaform()],
})

The Vite plugin also rewrites attaform/zod imports at build time to attaform/zod-v3 or attaform/zod-v4 β€” your bundle ships only the adapter you actually use.

App-wide options β€” install the Vue plugin if you want to set defaults or disable devtools:

// main.ts
import { createApp } from 'vue'
import { createAttaform } from 'attaform'

createApp(App)
  .use(createAttaform({ defaults: { debounceMs: 100 } }))
  .mount('#app')

Recommended tsconfig

We pair well with noUncheckedIndexedAccess: true:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

It catches stale form.values.contacts[N] reads at compile time. Nuxt 3 / 4 sets this for you.

Quick start

<script setup lang="ts">
  import { z } from 'zod'
  import { useForm } from 'attaform/zod' // auto-detects Zod major

  const schema = z.object({
    username: z.string().min(2, 'At least 2 characters'),
    password: z.string().min(8, 'At least 8 characters'),
  })

  const form = useForm({ schema, key: 'signup' })

  const onSubmit = form.handleSubmit(async (values) => {
    await $fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) })
  })
</script>

<template>
  <form @submit.prevent="onSubmit">
    <input v-register="form.register('username')" placeholder="Username" />
    <small v-if="form.errors.username?.[0]">{{ form.errors.username[0].message }}</small>

    <input v-register="form.register('password')" type="password" placeholder="Password" />
    <small v-if="form.errors.password?.[0]">{{ form.errors.password[0].message }}</small>

    <button :disabled="form.meta.isSubmitting">Sign up</button>
  </form>
</template>

useForm({ schema, key }) returns a Pinia-style reactive object β€” read leaves directly, no .value:

  • form.values β€” current values. form.values.username, form.values.address.city.
  • form.errors β€” per-field errors, keyed by dotted path. form.errors.username?.[0]?.message.
  • form.fields β€” per-field flags (dirty, touched, errors, blank, …). form.fields.username.dirty.
  • form.meta β€” form-level flags + counters (isSubmitting, isValid, canUndo, submitCount, the flat meta.errors aggregate, the per-mount instanceId, …).
  • form.register(path) β€” typed two-way binding; pair with v-register on <input> / <textarea> / <select>.
  • form.handleSubmit(onValid, onInvalid?) β€” runs validation, dispatches. The valid callback receives the strict zod-inferred type.
  • form.setValue(path, value), form.reset(), field-array helpers, undo / redo, persistence β€” see the API reference.

Features

  • Schema-driven types β€” every path, value, and error is inferred from the schema; no any.
  • Live validation β€” validateOn: 'change' by default with synchronous debounceMs: 0; 'blur' and 'submit' (opt-out) modes available; async refinements await before submit dispatches.
  • Schema-driven coercion β€” string DOM input β†’ schema's typed slot (stringβ†’number, stringβ†’boolean) at the directive layer. Default-on; pass useForm({ coerce: false }) to disable or a custom CoercionRegistry to extend.
  • Register transforms β€” register('email', { transforms: [trim, lowercase] }) runs sync user-input normalization before storage commit. See recipe.
  • Discriminated-union variant memory β€” switching a discriminator (notify.channel: 'email' β†’ 'sms' β†’ 'email') restores the previous variant's typed subtree by default. Set useForm({ rememberVariants: false }) to drop on switch.
  • Field arrays β€” append / prepend / insert / remove / swap / move / replace, fully typed at the call site.
  • Drafts + undo / redo β€” per-field opt-in persistence (localStorage / sessionStorage / IndexedDB / custom backend) and a bounded undo stack.
  • Server errors β€” parseApiErrors(payload) normalises a { message, code }[] wire format; pair with form.setFieldErrors(...). User errors survive schema revalidation.
  • Stable error codes β€” every ValidationError carries code: string. Library codes (atta:) live on the exported AttaformErrorCode enum; adapter codes use a zod: prefix; consumers pick their own (api:, auth:, …).
  • Clearable required fields β€” the unset sentinel marks a field displayed-empty while storage holds the schema's slim default. Submit fails with 'No value supplied' for required schemas; .optional() / .nullable() / .default(N) opt out.
  • SSR β€” Nuxt handles the payload round-trip automatically; bare Vue uses renderAttaformState / hydrateAttaformState (recipe).

Documentation

Status

Pre-1.0. SemVer applies from v1.0 onward; 0.x minor bumps may still include breaking changes, each documented under docs/migration/.

License

MIT β€” see LICENSE.

About

A fully type-safe, schema-driven form library that gives you superpowers. Chemical X included.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors