{"id":50446026,"url":"https://github.com/einhasad/vue-form","last_synced_at":"2026-05-31T21:32:27.534Z","repository":{"id":354487626,"uuid":"1223863396","full_name":"einhasad/vue-form","owner":"einhasad","description":"Fully headless Vue 3 form library. Composables, headless classes, and validator factories — bring your own UI. Optional Ant Design Vue adapter at the ./ant-design subpath.","archived":false,"fork":false,"pushed_at":"2026-04-28T19:26:42.000Z","size":193,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-28T20:27:59.452Z","etag":null,"topics":["ant-design-vue","composables","form","form-validation","forms","headless","headless-ui","typescript","validation","vue","vue-form","vue3","vuejs"],"latest_commit_sha":null,"homepage":"https://einhasad.github.io/vue-form/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/einhasad.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-28T18:19:04.000Z","updated_at":"2026-04-28T19:26:45.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/einhasad/vue-form","commit_stats":null,"previous_names":["einhasad/vue-form"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/einhasad/vue-form","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/einhasad%2Fvue-form","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/einhasad%2Fvue-form/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/einhasad%2Fvue-form/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/einhasad%2Fvue-form/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/einhasad","download_url":"https://codeload.github.com/einhasad/vue-form/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/einhasad%2Fvue-form/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33750474,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-31T02:00:06.040Z","response_time":95,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ant-design-vue","composables","form","form-validation","forms","headless","headless-ui","typescript","validation","vue","vue-form","vue3","vuejs"],"created_at":"2026-05-31T21:32:26.664Z","updated_at":"2026-05-31T21:32:27.516Z","avatar_url":"https://github.com/einhasad.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @einhasad-vue/vue-form\n\n[![npm version](https://img.shields.io/npm/v/@einhasad-vue/vue-form.svg?style=flat-square)](https://www.npmjs.com/package/@einhasad-vue/vue-form)\n[![CI](https://img.shields.io/github/actions/workflow/status/einhasad/vue-form/ci.yml?branch=main\u0026style=flat-square\u0026label=CI)](https://github.com/einhasad/vue-form/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE)\n[![Vue 3](https://img.shields.io/badge/vue-3.3+-42b883.svg?style=flat-square)](https://vuejs.org/)\n[![TypeScript](https://img.shields.io/badge/typescript-strict-3178c6.svg?style=flat-square)](https://www.typescriptlang.org/)\n\nA fully headless Vue 3 form library: form state, field state, validation, and a wildcard-pattern API for array-field rules. The main entry ships **no components, no widgets, no styling.** Optional adapter at `@einhasad-vue/vue-form/ant-design` if you're on Ant Design Vue and want the boilerplate already written.\n\n🚀 **[Live demo →](https://einhasad.github.io/vue-form/)** — runs entirely in-browser with mocked APIs (MSW).\n\n- **Headless core.** `Form` and `Field` are pure TypeScript classes. A thin Vue layer (`useProvideForm`, `useForm`, `useField`) binds them to reactivity. No DOM, no styles, no components.\n- **UI-library independent.** Bring your own UI kit. The optional `./ant-design` subpath is a thin opt-in adapter; everything else stays vanilla.\n- **Transport-agnostic.** Throws what your API throws; the library never inspects rejection shapes. You translate failures into per-field or form-level errors.\n- **Multiple errors per field.** Validation collects every failing rule, not stop-at-first.\n- **Validators are plain functions.** Sync or async, return `FieldError | undefined`. No rule-object indirection.\n- **Server-driven schemas.** External callers can install rules at runtime, including wildcard patterns for array fields with `addFieldValidatorsByPattern(\"parts.*.sku\", …)`.\n- **All strings overridable.** One call to `setStrings(partial)` retargets every built-in error message.\n\n## Install\n\n```sh\nnpm install @einhasad-vue/vue-form\n```\n\nPeer-deps: `vue ^3.3`.\n\n## Mental model\n\nThe library has **no `\u003cForm\u003e` component**. You create the form context yourself with `useProvideForm()`, then build the rest of the form however you want — `\u003ca-form\u003e`, plain `\u003cform\u003e`, multi-step wizard, anything.\n\n```vue\n\u003cscript setup lang=\"ts\"\u003e\nimport { ref } from 'vue'\nimport { useProvideForm, rules } from '@einhasad-vue/vue-form'\nimport TextInput from './widgets/TextInput.vue'  // your widget\n\n// 1. Provide the form context to descendants. useField calls in child\n//    components find this via inject.\nconst formCtx = useProvideForm()\nconst { formErrors, loading } = formCtx\n\nconst data = ref({ name: '', email: '' })\n\nasync function send(payload: typeof data.value) {\n  const r = await fetch('/api/users', { method: 'POST', body: JSON.stringify(payload) })\n  if (!r.ok) throw { response: { status: r.status, data: await r.json() } }\n  return r.json()\n}\n\n// 2. You own the submit flow. Validate, send, translate errors.\nasync function onSubmit() {\n  formCtx.clearErrors()\n  if (!formCtx.validateAll()) return\n  if (!(await formCtx.validateAllAsync())) return\n  loading.value = true\n  try {\n    const result = await send(data.value)\n    // ... handle success\n  } catch (raw) {\n    const r = raw as { response?: { status?: number; data?: { result?: { field: string; message: string }[] } } }\n    if (r?.response?.status === 422 \u0026\u0026 Array.isArray(r.response.data?.result)) {\n      for (const e of r.response.data.result) {\n        formCtx.setFieldErrors(e.field, [{ message: e.message }])\n      }\n    } else {\n      formCtx.setFormErrors([{ message: 'Submit failed' }])\n    }\n  } finally {\n    loading.value = false\n  }\n}\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cform @submit.prevent=\"onSubmit\"\u003e\n    \u003cTextInput\n      v-model=\"data.name\"\n      attribute=\"name\"\n      :validators=\"[rules.required('Name'), rules.minLen(3, 'Name')]\"\n    /\u003e\n    \u003cTextInput\n      v-model=\"data.email\"\n      attribute=\"email\"\n      :validators=\"[rules.required('Email'), rules.email('Email')]\"\n    /\u003e\n\n    \u003cul v-if=\"formErrors.length\"\u003e\n      \u003cli v-for=\"(e, i) in formErrors\" :key=\"i\"\u003e{{ e.message }}\u003c/li\u003e\n    \u003c/ul\u003e\n\n    \u003cbutton type=\"submit\" :disabled=\"loading\"\u003eSave\u003c/button\u003e\n  \u003c/form\u003e\n\u003c/template\u003e\n```\n\nA widget is anything that calls `useField`:\n\n```vue\n\u003c!-- TextInput.vue (your code, not shipped) --\u003e\n\u003cscript setup lang=\"ts\"\u003e\nimport { computed } from 'vue'\nimport { useField, type Validator } from '@einhasad-vue/vue-form'\n\nconst props = defineProps\u003c{\n  modelValue: string\n  attribute: string\n  validators?: Validator[]\n}\u003e()\nconst emit = defineEmits\u003c{ 'update:modelValue': [string] }\u003e()\n\nconst value = computed\u003cstring\u003e({\n  get: () =\u003e props.modelValue ?? '',\n  set: (v) =\u003e emit('update:modelValue', v),\n})\nconst f = useField({ attribute: props.attribute, modelValue: value, validators: props.validators })\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cinput v-model=\"value\" @blur=\"f.onBlur\" /\u003e\n  \u003cspan v-if=\"f.firstError.value\"\u003e{{ f.firstError.value.message }}\u003c/span\u003e\n\u003c/template\u003e\n```\n\n## API\n\n### Composables\n\n| Composable | Returns |\n| --- | --- |\n| `useProvideForm()` | A `FormContext`. Call once at the form root — `useField` finds it via `inject`. |\n| `useForm()` | The `FormContext` from the nearest `useProvideForm` ancestor. Throws if missing. |\n| `useField({ attribute, modelValue, validators? })` | `{ errors, isInvalid, firstError, validate, validateAsync, onBlur }`. Registers on mount, unregisters on unmount. |\n\n### `FormContext`\n\n| Member | Purpose |\n| --- | --- |\n| `validateAll()` / `validateAllAsync()` | Run sync / async validators across every registered field. Return `true` when no errors. |\n| `clearErrors()` | Clears every field's errors and the form-level errors list. |\n| `setFieldErrors(attribute, errors)` | Attach error messages to a specific attribute (incl. dotted paths like `parts.0.sku`). |\n| `setFormErrors(errors)` | Top-level form errors. Render them however you want. |\n| `setFieldValidators(attribute, validators)` | Replace a registered field's validators. |\n| `addFieldValidator(attribute, validator)` | Append one validator. |\n| `addFieldValidatorsByPattern(pattern, validators)` | Layer validators onto every field whose attribute matches a dotted wildcard (e.g. `parts.*.sku`). Applies to fields registered now AND any registered later. |\n| `getField(attribute)` | The raw `FieldHandle`. |\n| `formErrors: Ref\u003cFieldError[]\u003e` | Reactive form-level errors. |\n| `loading: Ref\u003cboolean\u003e` | Form-level loading flag. The library does not toggle this — your submit flow does. |\n| `fields: Ref\u003cFieldHandle[]\u003e` | Reactive list of registered fields. |\n\n### Validator factories (`rules`)\n\n```ts\nimport { rules } from '@einhasad-vue/vue-form'\n```\n\n`rules.required(attr?, override?)`, `rules.minLen(n, …)`, `rules.maxLen`, `rules.minNum`, `rules.maxNum`, `rules.pattern(re, …)`, `rules.email`, `rules.uniqueIn(siblings, currentIndex, key, override?)`.\n\nAll validators are plain functions:\n\n```ts\ntype Validator = (value: unknown) =\u003e FieldError | undefined | Promise\u003cFieldError | undefined\u003e\n```\n\nRoll your own:\n\n```ts\nconst vinUnique: Validator = async (v) =\u003e {\n  if (!v) return undefined\n  const taken = await fetch(`/api/vins/${v}`).then((r) =\u003e r.json())\n  return taken ? { message: 'VIN already registered', key: 'vinUnique' } : undefined\n}\nformCtx.addFieldValidator('vin', vinUnique)\n```\n\n### Strings / i18n\n\n```ts\nimport { setStrings } from '@einhasad-vue/vue-form'\nsetStrings({\n  required: '{attr} is required',\n  minLen: '{attr} must be at least {min} characters',\n})\n```\n\n`Strings = typeof en` — overrides are type-checked against the canonical shape.\n\n### Headless subpath\n\nFor pure-TS use (testing, server-side, non-Vue contexts):\n\n```ts\nimport { Form, Field, rules, setStrings } from '@einhasad-vue/vue-form/headless'\n```\n\nNo Vue, no DOM. Same `Form` / `Field` classes the Vue layer composes.\n\n### Ant Design Vue adapter\n\nOpt-in widget pack that wraps `\u003ca-input\u003e`, `\u003ca-select\u003e`, `\u003ca-date-picker\u003e`, etc. Each widget calls `useField` internally and renders an `\u003ca-form-item\u003e` for label + error layout — same component contract as a hand-rolled widget. **Not included unless you import from this subpath**, so the core lib stays free of antd code.\n\n```sh\nnpm install ant-design-vue dayjs   # peer-deps for the adapter\n```\n\n```ts\n// main.ts — register Antd globally so the adapter's templates resolve a-* tags.\nimport Antd from 'ant-design-vue'\nimport 'ant-design-vue/dist/reset.css'\nimport { createApp } from 'vue'\n\ncreateApp(App).use(Antd).mount('#app')\n```\n\n```ts\n// Form.vue — import widgets from the subpath.\nimport {\n  TextInput, NumberInput, TextareaInput, SelectInput,\n  CheckboxInput, CheckboxGroup, RadioGroup, SwitchInput,\n  CurrencyInput, PhoneInput,\n  DatePicker, DateRangePicker, TimePicker,\n  SearchSelect,\n} from '@einhasad-vue/vue-form/ant-design'\n```\n\nEach widget exposes the same prop surface: `v-model`, `attribute`, `label?`, `required?`, `disabled?`, `validators?`, plus widget-specific props (`items`, `searchCallback`, `step`, etc.). Use them inside `useProvideForm()` exactly like any other `useField`-based widget.\n\nThe adapter bundles to ~3 kB gzipped. `ant-design-vue` and `dayjs` are externalized — they're declared as **optional peer dependencies**, so the core install doesn't drag them in.\n\n## Server-driven validation schemas\n\nA common pattern: the backend is the source of truth for validation rules. Fetch a schema on mount and install validators — including ones for array items that don't exist yet.\n\n```ts\nonMounted(async () =\u003e {\n  const schema = await fetch('/api/validation/schema/vehicle').then((r) =\u003e r.json())\n  // {\n  //   \"name\":          [{ \"kind\": \"required\" }, { \"kind\": \"minLen\", \"n\": 3 }],\n  //   \"parts.*.sku\":   [{ \"kind\": \"required\" }, { \"kind\": \"maxLen\", \"n\": 40 }],\n  //   \"parts.*.qty\":   [{ \"kind\": \"required\" }, { \"kind\": \"minNum\", \"n\": 1 }],\n  // }\n  for (const [key, ruleList] of Object.entries(schema)) {\n    const validators = ruleList.map(buildValidator)  // your rule-kind → Validator mapper\n    if (key.includes('*')) formCtx.addFieldValidatorsByPattern(key, validators)\n    else                   formCtx.setFieldValidators(key, validators)\n  }\n})\n```\n\n`addFieldValidatorsByPattern` is **additive** (layers on top of inline `:validators`) and **forward-applying** — when the user clicks \"Add row\" and a new field registers with attribute `parts.7.sku`, the matching pattern's validators are applied automatically.\n\n`*` matches one dotted segment: `parts.*.sku` matches `parts.0.sku` but not `parts.0.attrs.sku`.\n\n## Array fields\n\nThere is no `\u003cNestedForm\u003e`. Render `v-for` with a dotted attribute path and you have it:\n\n```vue\n\u003cbutton type=\"button\" @click=\"rows.push({ sku: '', qty: 1 })\"\u003eAdd row\u003c/button\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\u003ctr\u003e\u003cth\u003eSKU\u003c/th\u003e\u003cth\u003eQty\u003c/th\u003e\u003cth /\u003e\u003c/tr\u003e\u003c/thead\u003e\n  \u003ctbody\u003e\n    \u003ctr v-for=\"(row, i) in rows\" :key=\"i\"\u003e\n      \u003ctd\u003e\n        \u003cTextInput\n          v-model=\"row.sku\"\n          :attribute=\"`parts.${i}.sku`\"\n          :validators=\"[rules.uniqueIn(rows, i, 'sku')]\"\n        /\u003e\n      \u003c/td\u003e\n      \u003ctd\u003e\u003cNumberInput v-model=\"row.qty\" :attribute=\"`parts.${i}.qty`\" /\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003cbutton type=\"button\" @click=\"rows.splice(i, 1)\"\u003eDelete\u003c/button\u003e\u003c/td\u003e\n    \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n```\n\nPair with `addFieldValidatorsByPattern(\"parts.*.sku\", …)` and the schema-driven rules apply to every row, present and future. Server 422 errors with dotted field paths (`parts.0.sku`) attach to the right row out of the box via `setFieldErrors`.\n\n## Examples\n\n`examples/` is a runnable workspace with two demos that both consume `@einhasad-vue/vue-form/ant-design`:\n\n- **Ant Design (in-process mock)** — synthetic `services.ts`, no network.\n- **Ant Design (MSW server-driven)** — real `fetch()` calls intercepted by [MSW v2](https://mswjs.io/), schema fetched from `GET /api/validation/schema/vehicle`, 422 envelopes decoded into per-field errors (incl. nested `parts.0.sku`).\n\nThe MSW demo is the closest to a production wire-up. Both views render an event log below the form so you can see the lifecycle (`request` / `response` / `field-error` / `validator` / …) as you interact.\n\n```sh\nnpm install\nnpm run dev --workspace=examples   # http://localhost:5173\n```\n\nBoth demo views show the full submit-orchestration pattern:\n- `useProvideForm()` at the form root\n- Validate sync → validate async → `send()` → translate 422 envelopes via `setFieldErrors` (incl. nested paths)\n- Server-driven schema fetched on mount, with wildcard patterns for array rules\n\n## Development\n\n```sh\nnpm install\nnpm run dev --workspace=examples   # boots the demo at http://localhost:5173\nnpm test                           # 63 tests covering headless classes, rules, Vue bridge\nnpm run typecheck                  # vue-tsc, root + examples\nnpm run build                      # ESM + CJS bundles to dist/, with .d.ts via vite-plugin-dts\n```\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feinhasad%2Fvue-form","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Feinhasad%2Fvue-form","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feinhasad%2Fvue-form/lists"}