@foormjs/vue
v0.2.5
Published
@foormjs/vue
Maintainers
Readme
@foormjs/vue
Renderless Vue components for ATScript-defined forms. Bring your own UI components and wrap them in OoForm / OoField to get automatic validation, computed properties, and reactive form state — all driven by .as schema files.
Install
pnpm add @foormjs/vue @foormjs/atscript vue @atscript/core @atscript/typescript
# or
npm install @foormjs/vue @foormjs/atscript vue @atscript/core @atscript/typescriptYou also need the ATScript Vite plugin for .as file support:
pnpm add -D unplugin-atscript @vitejs/plugin-vueQuick Start
1. Configure ATScript
// atscript.config.ts
import { defineConfig } from '@atscript/core'
import ts from '@atscript/typescript'
import { foormPlugin } from '@foormjs/atscript/plugin'
export default defineConfig({
rootDir: 'src',
plugins: [ts(), foormPlugin()],
})2. Configure Vite
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import atscript from 'unplugin-atscript/vite'
export default defineConfig({
plugins: [atscript(), vue()],
})3. Define a form schema
// src/forms/login.as
@foorm.title 'Sign In'
@foorm.submit.text 'Log In'
export interface LoginForm {
@meta.label 'Email'
@meta.placeholder '[email protected]'
@foorm.autocomplete 'email'
@meta.required 'Email is required'
@foorm.order 1
email: string.email
@meta.label 'Password'
@meta.placeholder 'Enter password'
@foorm.type 'password'
@meta.required 'Password is required'
@foorm.order 2
password: string
}4. Use in a Vue component
<script setup lang="ts">
import { OoForm, useFoorm } from '@foormjs/vue'
import { LoginForm } from './forms/login.as'
const { def, formData } = useFoorm(LoginForm)
function handleSubmit(data: typeof formData) {
console.log('submitted', data)
}
</script>
<template>
<OoForm :def="def" :form-data="formData" :types="{}" @submit="handleSubmit" />
</template>OoForm renders default HTML inputs for all standard field types out of the box. For production use, you'll want to supply your own components via the types prop.
AI Agent Skills
@foormjs/vue ships an AI agent skill for Claude Code, Cursor, Windsurf, Codex, and other compatible agents. The skill teaches your agent the library's APIs, patterns, and best practices so it can help you write correct code without hallucinating.
Install the skill into your agent:
# Project-local (recommended — version-locked, commits with your repo)
npx @foormjs/vue setup-skills
# Global (available across all your projects)
npx @foormjs/vue setup-skills --globalRestart your agent after installing.
Auto-update on install — to keep the skill in sync whenever you upgrade the package, add this to your project's package.json:
{
"scripts": {
"postinstall": "npx @foormjs/vue setup-skills --postinstall",
},
}Advanced Usage
Custom Components by Type
Map field types to your UI components:
<script setup lang="ts">
import { OoForm } from '@foormjs/vue'
import MyTextInput from './MyTextInput.vue'
import MySelect from './MySelect.vue'
import MyCheckbox from './MyCheckbox.vue'
const typeComponents = {
text: MyTextInput,
select: MySelect,
checkbox: MyCheckbox,
}
</script>
<template>
<OoForm :def="def" :form-data="formData" :types="typeComponents" @submit="onSubmit" />
</template>Every field with type: 'text' will render MyTextInput, every type: 'select' renders MySelect, etc.
Custom Components by Name
Use @foorm.component in your schema to assign a named component to a specific field:
@meta.label 'Rating'
@foorm.component 'StarRating'
@foorm.order 5
rating?: numberThen pass named components via the components prop:
<template>
<OoForm
:def="def"
:form-data="formData"
:types="typeComponents"
:components="{ StarRating: MyStarRating }"
@submit="onSubmit"
/>
</template>Named components take priority over type-based components.
Building a Custom Component
Custom components receive TFoormComponentProps as their props:
<script setup lang="ts">
import type { TFoormComponentProps } from '@foormjs/vue'
const props = defineProps<TFoormComponentProps<string>>()
</script>
<template>
<div :class="{ disabled, error: !!error }">
<label>{{ label }}</label>
<input
:value="model.value"
@input="model.value = ($event.target as HTMLInputElement).value"
@blur="onBlur"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
/>
<span v-if="error" class="error">{{ error }}</span>
<span v-else-if="hint" class="hint">{{ hint }}</span>
</div>
</template>Key props available to your component:
| Prop | Type | Description |
| ------------------ | ----------------------------- | ---------------------------------------------------------- |
| model | { value: V } | Reactive model — bind with v-model="model.value" |
| value | unknown? | Phantom display value (@foorm.value / @foorm.fn.value) |
| onBlur | (e: FocusEvent) => void | Triggers validation on blur |
| error | string? | Validation error message |
| label | string? | Resolved field label |
| description | string? | Resolved field description |
| hint | string? | Resolved hint text |
| placeholder | string? | Resolved placeholder |
| disabled | boolean? | Whether the field is disabled |
| hidden | boolean? | Whether the field is hidden |
| readonly | boolean? | Whether the field is read-only |
| optional | boolean? | Whether the field is optional |
| required | boolean? | Whether the field is required |
| type | string | The field input type |
| altAction | TFoormAltAction? | Alternate action { id, label } from @foorm.altAction |
| options | TFoormEntryOptions[]? | Options for select/radio fields |
| name | string? | Field name |
| maxLength | number? | Max length constraint |
| autocomplete | string? | HTML autocomplete value |
| field | FoormFieldDef? | Full field definition for advanced use |
| title | string? | Resolved title for object/array fields |
| level | number? | Nesting level (root=0, increments per nested object/array) |
| class | string \| object? | CSS class(es) from @foorm.fn.classes |
| style | string \| object? | Inline styles from @foorm.fn.styles |
| onRemove | () => void? | Callback to remove this item from its parent array |
| canRemove | boolean? | Whether removal is allowed (respects minLength) |
| removeLabel | string? | Label for the remove button |
| arrayIndex | number? | Zero-based index when rendered as an array item |
| onToggleOptional | (enabled: boolean) => void? | Toggle an optional field on/off |
Arrays
Array fields are handled automatically. Define them in your .as schema:
@meta.label 'Tags'
@foorm.array.add.label 'Add tag'
@foorm.array.remove.label 'x'
@expect.maxLength 5, 'Maximum 5 tags'
tags: string[]
@meta.label 'Addresses'
@foorm.title 'Addresses'
@foorm.array.add.label 'Add address'
addresses: {
@meta.label 'Street'
@meta.required 'Street is required'
street: string
@meta.label 'City'
city: string
}[]OoForm renders arrays with add/remove buttons, inline editing for primitives, and sub-form cards for objects. Array-level validation (@expect.minLength, @expect.maxLength) is displayed below the add button.
Union arrays ((ObjectType | string)[]) render a variant selector per item and offer one add button per variant.
The remove button is the responsibility of the wrapping component:
- Object array items: The object component receives
onRemove,canRemove, andremoveLabelviaTFoormComponentProps. - Primitive array items: The field component receives
onRemove,canRemove, andremoveLabelas props (since there is no object wrapper around them).
Nested Groups
Use @foorm.title on a nested object field to render it as a titled, visually distinct section:
@foorm.title 'Settings'
settings: {
@meta.label 'Notify by email'
emailNotify: foorm.checkbox
@meta.label 'Page size'
@foorm.type 'number'
pageSize?: number
}Without @foorm.title, nested object fields flatten into the parent form. With it, they render as an indented group with a title header. Groups can be nested to any depth.
Scoped Slots
OoForm provides scoped slots for full layout control:
<template>
<OoForm :def="def" :form-data="formData" :types="typeComponents" @submit="onSubmit">
<!-- Form header (rendered before fields) -->
<template #form.header="{ clearErrors, reset, setErrors, formContext, disabled }">
<h1>Form Header</h1>
</template>
<!-- Content before/after fields -->
<template #form.before="{ clearErrors, reset, setErrors }">
<p>All fields are required unless marked optional.</p>
</template>
<template #form.after="{ clearErrors, reset, setErrors, disabled, formContext }">
<p v-if="disabled">Please fill out all required fields.</p>
</template>
<!-- Custom submit button -->
<template #form.submit="{ text, disabled, clearErrors, reset, setErrors, formContext }">
<button type="submit" :disabled="disabled" class="my-btn">{{ text }}</button>
</template>
<!-- Footer (rendered after submit) -->
<template #form.footer="{ disabled, clearErrors, reset, setErrors, formContext }">
<p>By submitting, you agree to our terms.</p>
</template>
</OoForm>
</template>Form Context
Pass runtime data (user session, feature flags, dynamic options) to computed functions and validators:
<script setup lang="ts">
const formContext = {
cityOptions: [
{ key: 'nyc', label: 'New York' },
{ key: 'la', label: 'Los Angeles' },
],
user: { role: 'admin' },
}
</script>
<template>
<OoForm
:def="def"
:form-data="formData"
:form-context="formContext"
:types="typeComponents"
@submit="onSubmit"
/>
</template>Context is accessible in ATScript function strings as the third argument:
@foorm.fn.options '(v, data, ctx) => ctx.cityOptions || []'
city?: foorm.selectServer-side Errors
Pass server-side validation errors directly to fields:
<script setup lang="ts">
import { ref } from 'vue'
const serverErrors = ref<Record<string, string>>({})
async function handleSubmit(data: any) {
const result = await api.submit(data)
if (result.errors) {
serverErrors.value = result.errors // e.g. { email: 'Already taken' }
}
}
</script>
<template>
<OoForm
:def="def"
:form-data="formData"
:errors="serverErrors"
:types="typeComponents"
@submit="handleSubmit"
/>
</template>Actions
Define alternate submit actions using the foorm.action primitive:
@meta.label 'Reset Password'
@foorm.altAction 'reset-password', 'Reset Password'
resetBtn: foorm.actionHandle the action event:
<template>
<OoForm
:def="def"
:form-data="formData"
:types="typeComponents"
@submit="onSubmit"
@action="onAction"
/>
</template>
<script setup lang="ts">
function onAction(name: string, data: any) {
if (name === 'reset-password') {
// handle reset password
}
}
</script>Paragraphs
Display static or computed text using the foorm.paragraph primitive:
@foorm.value 'Please fill out all required fields.'
info: foorm.paragraph
@foorm.fn.value '(v, data) => "Hello, " + (data.firstName || "guest") + "!"'
greeting: foorm.paragraphParagraphs are phantom fields — they are excluded from form data, validation, and TypeScript types. They only exist for display purposes.
API Reference
useFoorm(type)
Creates a reactive form definition and data object from an ATScript annotated type.
const { def, formData } = useFoorm(MyFormType)def—FoormDefwith root field, ordered fields, the source type, and a flatMapformData— Vuereactive()object with default values from the schema
OoForm
Renderless form wrapper component.
Props:
| Prop | Type | Required | Description |
| ----------------- | ------------------------------------------------------------------------ | -------- | ------------------------------------------------------- |
| def | FoormDef | Yes | Form definition from useFoorm() or createFoormDef() |
| formData | object | No | Reactive form data (created internally if omitted) |
| formContext | object | No | External context for computed functions and validators |
| firstValidation | 'on-change' \| 'touched-on-blur' \| 'on-blur' \| 'on-submit' \| 'none' | No | When to trigger first validation |
| components | Record<string, Component> | No | Named components (matched by @foorm.component) |
| types | Record<string, Component> | Yes | Type-based components (matched by field type) |
| errors | Record<string, string> | No | External error messages (e.g. server-side) |
Events:
| Event | Payload | Description |
| -------------------- | ----------------------------------------------- | --------------------------------------------------------------- |
| submit | formData | Emitted on valid form submission |
| error | { path: string; message: string }[] | Emitted when validation fails on submit |
| action | name, formData | Emitted when an action button is clicked (supported alt action) |
| unsupported-action | name, formData | Emitted for unrecognized action names |
| change | type: TFoormChangeType, path, value, formData | Emitted on field update, array add/remove, or union switch |
Slots:
| Slot | Scope | Description |
| ------------- | ---------------------------------------------------------------- | --------------------------- |
| form.header | { clearErrors, reset, setErrors, formContext, disabled } | Before fields |
| form.before | { clearErrors, reset, setErrors } | After header, before fields |
| form.after | { clearErrors, reset, setErrors, disabled, formContext } | After fields, before submit |
| form.submit | { text, disabled, clearErrors, reset, setErrors, formContext } | Submit button |
| form.footer | { disabled, clearErrors, reset, setErrors, formContext } | After submit |
OoField
Universal field renderer. Resolves component, props, validation, and nesting for any field type.
Props:
| Prop | Type | Description |
| ------------- | --------------- | -------------------------------------------------------------- |
| field | FoormFieldDef | Field definition from def.fields or def.rootField |
| error | string? | External error message |
| onRemove | () => void? | Callback to remove this item from its parent array |
| canRemove | boolean? | Whether removal is allowed (respects minLength) |
| removeLabel | string? | Label for the remove button (from @foorm.array.remove.label) |
| arrayIndex | number? | Zero-based index when rendered as an array item |
Default Components
All default type components are exported and can be used as-is or as reference implementations:
| Component | Field Type | Description |
| ------------- | ----------- | -------------------------------------------------- |
| OoInput | text, etc | Text/password/number input with OoFieldShell |
| OoSelect | select | Dropdown select with OoFieldShell |
| OoRadio | radio | Radio button group with OoFieldShell |
| OoCheckbox | checkbox | Boolean checkbox with OoFieldShell |
| OoParagraph | paragraph | Read-only text display |
| OoAction | action | Action button with altAction support |
| OoObject | object | Object container (title + OoIterator) |
| OoArray | array | Array container (add/remove + OoIterator per item) |
| OoUnion | union | Union variant picker + selected variant rendering |
| OoTuple | tuple | Fixed-length tuple via OoIterator |
Composables
| Export | Description |
| --------------------------------- | --------------------------------------------------------------------- |
| useFoorm(type) | Returns { def, formData } from an ATScript annotated type |
| useFoormArray(field, disabled?) | Array state management (keys, add/remove, constraints) |
| useFoormUnion(props) | Union variant state management |
| useConsumeUnionContext() | Consume and clear the __foorm_union injection |
| formatIndexedLabel() | Format label with array index prefix (e.g. "Address #1") |
| createDefaultTypes() | Returns a TFoormTypeComponents map with all default type components |
Types
| Export | Description |
| -------------------------- | ------------------------------------------------------------- |
| TFoormBaseComponentProps | Shared base props (disabled, hidden) |
| TFoormComponentProps | Unified props interface for ALL custom field components |
| TFoormTypeComponents | Required shape for the types prop on OoForm |
| TFoormChangeType | 'update' \| 'array-add' \| 'array-remove' \| 'union-switch' |
| TFoormUnionContext | Union context provided via __foorm_union inject |
For ATScript documentation, see atscript.moost.org.
License
MIT
