atscript-ui-forms
Install
npx skills add moostjs/atscript-ui # installs all atscript-ui skills (this one + general + tables + wf + styles)
npx skills add moostjs/atscript # sibling — .as language
pnpm add @atscript/core @atscript/typescript @atscript/ui @atscript/vue-form vue
pnpm add @atscript/ui-fns # opt-in: dynamic @ui.form.fn.* + @ui.form.validate
pnpm add @atscript/ui-styles vunor unocss # styling
pnpm add unplugin-vue-components # for AsResolver()
Quick start
// src/contact.as
@meta.label 'Contact'
@ui.form.submit.text 'Send'
export interface Contact {
@meta.label 'Name'
@meta.required 'Name is required'
@ui.form.placeholder 'Jane Doe'
name: string
@meta.label 'Email'
@meta.required 'Email is required'
@ui.form.placeholder 'jane@example.com'
email: string.email
@meta.label 'Message'
@ui.form.type 'textarea'
@expect.maxLength 500, 'Keep it under 500 characters'
message: string
}
<script setup lang="ts">
import { AsForm, createAsFormDef, createDefaultTypes } from "@atscript/vue-form";
import { Contact } from "./contact.as";
const { def, formData } = createAsFormDef(Contact);
const types = createDefaultTypes();
function onSubmit(data: Contact) {
// formData was { value: <Contact> } — vue-form unwrapped before emitting
console.log(data);
}
</script>
<template>
<AsForm :def="def" :form-data="formData" :types="types" @submit="onSubmit" />
</template>
AsResolver() from @atscript/ui-styles/vite auto-imports AsForm, AsField, AsIterator, AsCollapsible
in templates — no manual import needed if unplugin-vue-components is wired.
Invariants
| # |
Rule |
| 1 |
@ui.form.type is for built-in renderer ids only. Built-ins: text, password, textarea, number, decimal, select, radio, checkbox, multiselect, paragraph, action, date, datetime, time, plus structural object/array/union/tuple/ref. For custom renderers use @ui.form.component + the :components prop map. multiselect auto-dispatches on (literal | union)[] and on primitive-item arrays carrying @ui.form.options / @ui.form.fn.options; value model is T[]. |
| 2 |
@ui.form.fn.* and @ui.form.validate require installDynamicResolver() from @atscript/ui-fns. Call once at app startup before mounting any <AsForm> — in SSR apps from the client entry only (dynamic-fields.md). Without it, dynamic annotations silently evaluate to undefined and custom validators do nothing. |
| 3 |
Form data is wrapped: { value: domainData }. Pass it as :form-data or omit and let createAsFormDef produce one. Path helpers and composables unwrap automatically. When you supply a submitValidator, it receives the unwrapped domain data. |
| 4 |
Component resolution precedence: @ui.form.component (name → :components[name]) → @ui.form.type / @ui.type (name → :types[customType]) → structural field.type (:types[type]). Custom components belong in :components; the :types map is reserved for built-ins. |
| 5 |
Empty slots do NOT suppress fallback. <template #form.submit /> still renders the default Submit button. Use the explicit boolean prop (e.g. hide-submit) instead. |
| 6 |
Fresh fields suppress live validation until edit or submit. Items newly added via <AsArray> skip the field validator on first render to avoid surprise "required" errors on freshly-rendered slots. |
| 7 |
External errors are keyed by absolute dotted path. Pass :errors="{ 'address.street': 'msg', '__form': 'top-level' }". The __form key is reserved for form-wide banners. |
| 8 |
Union variant switching wipes data. Switching the selected variant of a discriminated union rewrites model.value to a fresh instance of the target variant's type. Use useAsUnion's stash inside one mount, or persist before switching for longer-lived recovery. |
| 9 |
AsAction is a phantom field. It renders a button that emits action from <AsForm> with (name: string, data: TFormData). The phantom field carries @ui.form.action 'id', 'label' and no real data slot. |
| 10 |
@ui.form.action on a regular input renders an inline link. On a non-phantom input it appears as a link-styled button in the field footer and emits the same action event; in <AsWfForm> it resolves via @WfAction(). See actions-refs.md. |
| 11 |
@ui.form.pushDown renders a field below the submit button in its own grid (same 12-col layout; @ui.form.order / @ui.form.grid.* apply). The default AsAction also reads @ui.form.attr 'text' (prefix before the link) and 'align' (left/center/right, default left) — phantom ui.action fields only. All three carry into <AsWfForm> unchanged. See actions-refs.md. |
Key imports
// Tier 1 — primary (auto-imported by AsResolver)
import { AsForm, AsField, AsIterator, AsCollapsible } from "@atscript/vue-form";
// Tier 2 — defaults (swap targets; subpath imports also available)
import {
AsFieldShell,
AsInput,
AsNumber,
AsDecimal,
AsSelect,
AsRadio,
AsCheckbox,
AsDate,
AsDatetime,
AsTime,
AsParagraph,
AsAction,
AsObject,
AsArray,
AsUnion,
AsTuple,
AsRef,
AsMultiSelect,
} from "@atscript/vue-form";
// Composables
import {
useAsForm,
useAsFormPatch,
useAsField,
useAsState,
useAsArray,
useAsUnion,
useAsTuple,
useAsValueHelp,
useAsDropdown,
useAsOptionalAddFlow,
useAsTriStateCheckbox,
useAsDate,
useAsDecimal,
useAsNumber,
useAsDualInput,
useAsLocale,
useAsPath,
useAsTypeMap,
useAsData,
useAsErrorDismiss,
useAsNestedSectionsStore,
useAsDescendantErrorCounts,
useAsExternalErrors,
useAsUnionVariant,
} from "@atscript/vue-form";
// Factories + providers
import {
createDefaultTypes,
createAsFormDef,
formatIndexedLabelParts,
provideAsLocale,
provideAsNestedSectionsStore,
} from "@atscript/vue-form";
// Types
import type {
TAsComponentProps,
TAsComponentEmits,
TAsCollapsibleProps,
TAsCollapsibleSlots,
TAsChangeType,
TAsTypeComponents,
TAsUnionContext,
TFormState,
TFormRule,
UseAsFieldOptions,
UseAsFieldReturn,
UseAsFormOptions,
UseAsFormReturn,
} from "@atscript/vue-form";
References — load only what's needed
| Domain |
File |
When |
| First contact |
getting-started.md |
Minimal mount, createAsFormDef, :types / :components / :errors props, the submit/action emit contract |
<AsForm> reference |
forms.md |
AsForm/AsField/AsIterator props/emits/slots, default type map, validation (@expect.*, @meta.required, firstValidation strategies, fresh-fields suppression, external errors, __form banner) |
| Structural fields |
structural-fields.md |
Arrays (scalar/object/nested, union-item), nested objects (collapsible, path nesting, provideAsNestedSectionsStore), discriminated unions (variant detection, useAsUnion stash), tuples (useAsTuple.fillMissing, positional labels) |
| Dynamic fields |
dynamic-fields.md |
@atscript/ui-fns: installDynamicResolver(), @ui.form.fn.* (label, hidden, disabled, readonly, options, value, attr, classes, styles, title, submit.text, submit.disabled), @ui.form.validate, TFnScope (v / data / context / entry), security model, FNPool caching |
| Customization |
customization.md |
Three-level override: :types (built-in id swap) → :components (custom name + @ui.form.component) → AsFieldShell wrap → fully custom root via useAsForm. The <AsForm> slot-props bag (canonical key list spread onto every slot) + per-slot extras + the empty-slot-≠-hidden rule. TAsComponentProps contract for custom components. Container renderers replacing an object field's section chrome (useAsVisibleFields, useAsFieldScope, useAsOptionalField, useAsLevel/provideAsNestedLevel, AsIterator :fields/:path-prefix/:levels, delegate arrays/unions to <AsField>). Locale providers (provideAsLocale, currency, units). |
| Actions + refs |
actions-refs.md |
@ui.form.action + AsAction (single + multi-action forms, submit text, conditional disable, @ui.form.pushDown below-submit placement, alt-action text/align via @ui.form.attr), @db.rel.FK + AsRef value-help (@db.http.path, @ui.dict.* on target, clientFactory for auth headers, ValueHelpClient flow) |
| Change tracking |
form-change-tracking.md |
Tracking form edits / dirty state / track-changes prop / useAsFormPatch; building an @atscript/db patch from a form for table.updateOne; gating Save on isDirty; per-field dirty to mark changed fields (isPathDirty/isDirtyPath/useAsField().isDirty/data-dirty); getPatch/getChanges/rebase; rebaseOnto 3-way merge (fold fresh server data into a dirty form) + buildFormRebase/applyFormChanges; nested replace-vs-merge; keyed-array $update/$insert/$remove; $cas OCC from @db.column.version |
| Aooth components |
aooth-components.md |
Reaching for the prebuilt @atscript/vue-aooth field components — AsConsentArray, AsPasswordRules, AsQrCode, AsCopy, AsSsoProviders — or one-click SSO/social-login provider buttons that fire a form action, or building a phantom display field driven by workflow context (ui.paragraph + @ui.form.fn.value + @wf.context.pass) |
| Collapsible sections |
collapsible-sections.md |
Wrapping a custom component in section chrome / adding a header-row action to a section / using <AsCollapsible> directly / full-bleed section dividers to a padded card or enclosing island (--as-inset) |
OCC-enabled edit forms
For tables annotated with @db.column.version, the server returns meta.versionColumn and auto-handles compare-and-set on update. Wire the form so the version field doesn't render as an input but the value still rides the wire:
import { createFormDef } from "@atscript/ui";
import { deserializeAnnotatedType } from "@atscript/typescript/utils";
import { VersionMismatchError } from "@atscript/db-client";
const meta = await client.meta();
const formDef = createFormDef(deserializeAnnotatedType(meta.type), {
versionColumn: meta.versionColumn,
});
async function onSubmit(data: unknown) {
try {
await client.update(data as never);
} catch (e) {
if (e instanceof VersionMismatchError) {
showError(`Row changed (current version: ${e.currentVersion}). Reload to continue.`);
} else {
throw e;
}
}
}
The version prop is excluded from def.fields[] (so <AsForm> doesn't paint it) but stays in flatMap and the underlying form data, so the PATCH body preserves it for the server's $cas lift. createTableDef does the symmetric thing on the table side — the version column never appears in column / filter / sort dialogs. See the atscript-db skill (OCC reference) for the server-side mechanics (@db.column.version, $cas, VersionMismatchError).
Customization
Forms expose three swap mechanisms, layered on the tier model:
- Tier 1 —
<AsForm>, <AsField>, <AsIterator> are the integration surface. Configure them via props and slots; if you need a fully custom shell, build one with useAsForm / useAsField directly.
- Tier 2 — the default field components (
AsInput, AsSelect, AsRadio, AsCheckbox, AsDate, AsParagraph, AsAction, AsObject, AsArray, AsUnion, AsTuple, AsRef, AsMultiSelect — see "Key imports"). These are what you swap.
- Tier 3 — internal composition helpers. Not directly tagged or swapped; their style classes ride with whichever defaults import them.
Swap a built-in renderer (:types)
:types maps built-in renderer ids (per invariant 1: text, select, date, …) to a component. Useful when you want every field of a given built-in type to render with your design system's input:
<script setup lang="ts">
import { createDefaultTypes } from "@atscript/vue-form";
import MyTextInput from "./MyTextInput.vue";
const types = { ...createDefaultTypes(), text: MyTextInput };
</script>
<template>
<AsForm :def="def" :form-data="formData" :types="types" />
</template>
Swap a specific field (:components + @ui.form.component)
:components looks up by a custom name the .as type opts into. Use this when you want one field — not every field of that type — to render with a custom widget (e.g. a country picker that's still a string to the rest of the system):
export interface Profile {
@meta.label 'Country'
@ui.form.component 'country-picker'
country: string
}
<script setup lang="ts">
import CountryPicker from "./CountryPicker.vue";
const components = { "country-picker": CountryPicker };
</script>
<template>
<AsForm :def="def" :form-data="formData" :components="components" />
</template>
:components takes precedence over :types (invariant 4). The :types map stays reserved for built-in ids — point custom names at :components and you keep the renderer ids available for genuine type-level overrides.
Wrap with AsFieldShell
When the framing (label, hint, error, required marker, description) is already what you want but the input itself isn't, wrap your custom input in AsFieldShell rather than re-implementing the chrome:
<script setup lang="ts">
import { AsFieldShell } from "@atscript/vue-form";
import type { TAsComponentProps } from "@atscript/vue-form";
defineProps<TAsComponentProps<string>>();
</script>
<template>
<AsFieldShell v-bind="$props">
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</AsFieldShell>
</template>
If instead you render a bare root (no AsFieldShell — e.g. a section or media block that doesn't want the label/error chrome), you must bind :class="props.class" (and :style="props.style") on that root yourself. class/style are declared props on TAsComponentProps, so they arrive as props.class — not $attrs — and are not auto-applied to the root; AsFieldShell does this for you. Omit it and the field loses its @ui.form.grid.colSpan/.rowSpan placement (renders at the wrong width). Binding props.class is sufficient at every span — the default full-width and any colSpan override both ride on it, so you don't add as-grid-item or col-span-* yourself.
Full contract for custom components (TAsComponentProps, TAsComponentEmits, locale providers) lives in customization.md.
Style consequence
If you swap AsSelect for a custom dropdown, the as-select-* shortcuts the default tagged drop out of your bundle automatically. Keep the default when its styling already fits your design system; for granular opt-out, see atscript-ui-styles (allShortcuts super-merge, swap in a narrower subset).
In-tree examples
@atscript/vue-aooth ships five production examples of the custom-component and phantom-field patterns — AsConsentArray, AsPasswordRules, AsQrCode, AsCopy, AsSsoProviders. They demonstrate useAsField inside a component, re-using compileFieldFn from @atscript/ui-fns for fn-string arrays, the phantom (ui.paragraph + @ui.form.fn.value + @wf.context.pass) display pattern, and — via AsSsoProviders — emitting a form action from a custom component (one-click: select + fire @ui.form.action). See aooth-components.md.
See also
Reference docs: https://ui.atscript.dev/forms/. Source: https://github.com/moostjs/atscript-ui.
1---2name: atscript-ui-forms3description: Render forms from `.as` types with `@atscript/vue-form`. Use with `<AsForm>`, `<AsField>`, `<AsIterator>`; for `.as` types with `@ui.form.*` / `@ui.form.fn.*`; for validation (`@meta.required`, `@expect.*`, `@ui.form.validate`); for arrays, nested objects, unions, tuples; for form actions (`@ui.form.action`) or FK value-help (`@db.rel.FK` → `AsRef`); for change tracking — dirty state, an `@atscript/db` patch, per-field dirty (mark changed fields), or a 3-way rebase folding fresh server data into unsaved edits (`track-changes`, `useAsFormPatch`, `getPatch`, `$cas`, `isPathDirty`, `isDirtyPath`, `useAsField().isDirty`, `data-dirty`, `rebaseOnto`); to override defaults (`:types` / `:components`); for custom fields (`TAsComponentProps`, `AsFieldShell`, `@atscript/vue-aooth`); or `useAsForm` / `useAsField` / `useAsArray` / `useAsUnion` / `useAsTuple`. Out of scope: tables (`atscript-ui-tables`), HTTP workflow forms (`atscript-ui-wf`), styling (`atscript-ui-styles`), `createFormDef` (general `atscript-ui` skill).4---56# atscript-ui-forms78## Install910```bash11npx skills add moostjs/atscript-ui # installs all atscript-ui skills (this one + general + tables + wf + styles)12npx skills add moostjs/atscript # sibling — .as language13```1415```bash16pnpm add @atscript/core @atscript/typescript @atscript/ui @atscript/vue-form vue17pnpm add @atscript/ui-fns # opt-in: dynamic @ui.form.fn.* + @ui.form.validate18pnpm add @atscript/ui-styles vunor unocss # styling19pnpm add unplugin-vue-components # for AsResolver()20```2122## Quick start2324```atscript25// src/contact.as26@meta.label 'Contact'27@ui.form.submit.text 'Send'28export interface Contact {29 @meta.label 'Name'30 @meta.required 'Name is required'31 @ui.form.placeholder 'Jane Doe'32 name: string3334 @meta.label 'Email'35 @meta.required 'Email is required'36 @ui.form.placeholder 'jane@example.com'37 email: string.email3839 @meta.label 'Message'40 @ui.form.type 'textarea'41 @expect.maxLength 500, 'Keep it under 500 characters'42 message: string43}44```4546```vue47<script setup lang="ts">48import { AsForm, createAsFormDef, createDefaultTypes } from "@atscript/vue-form";49import { Contact } from "./contact.as";5051const { def, formData } = createAsFormDef(Contact);52const types = createDefaultTypes();5354function onSubmit(data: Contact) {55 // formData was { value: <Contact> } — vue-form unwrapped before emitting56 console.log(data);57}58</script>5960<template>61 <AsForm :def="def" :form-data="formData" :types="types" @submit="onSubmit" />62</template>63```6465`AsResolver()` from `@atscript/ui-styles/vite` auto-imports `AsForm`, `AsField`, `AsIterator`, `AsCollapsible`66in templates — no manual import needed if `unplugin-vue-components` is wired.6768## Invariants6970| # | Rule |71| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |72| 1 | **`@ui.form.type` is for built-in renderer ids only.** Built-ins: `text`, `password`, `textarea`, `number`, `decimal`, `select`, `radio`, `checkbox`, `multiselect`, `paragraph`, `action`, `date`, `datetime`, `time`, plus structural `object`/`array`/`union`/`tuple`/`ref`. For custom renderers use `@ui.form.component` + the `:components` prop map. `multiselect` auto-dispatches on `(literal \| union)[]` and on primitive-item arrays carrying `@ui.form.options` / `@ui.form.fn.options`; value model is `T[]`. |73| 2 | **`@ui.form.fn.*` and `@ui.form.validate` require `installDynamicResolver()` from `@atscript/ui-fns`.** Call once at app startup before mounting any `<AsForm>` — in SSR apps from the **client entry only** ([dynamic-fields.md](references/dynamic-fields.md)). Without it, dynamic annotations silently evaluate to `undefined` and custom validators do nothing. |74| 3 | **Form data is wrapped: `{ value: domainData }`.** Pass it as `:form-data` or omit and let `createAsFormDef` produce one. Path helpers and composables unwrap automatically. When you supply a `submitValidator`, it receives the _unwrapped_ domain data. |75| 4 | **Component resolution precedence**: `@ui.form.component` (name → `:components[name]`) → `@ui.form.type` / `@ui.type` (name → `:types[customType]`) → structural `field.type` (`:types[type]`). Custom components belong in `:components`; the `:types` map is reserved for built-ins. |76| 5 | **Empty slots do NOT suppress fallback.** `<template #form.submit />` still renders the default Submit button. Use the explicit boolean prop (e.g. `hide-submit`) instead. |77| 6 | **Fresh fields suppress live validation until edit or submit.** Items newly added via `<AsArray>` skip the field validator on first render to avoid surprise "required" errors on freshly-rendered slots. |78| 7 | **External errors are keyed by absolute dotted path.** Pass `:errors="{ 'address.street': 'msg', '__form': 'top-level' }"`. The `__form` key is reserved for form-wide banners. |79| 8 | **Union variant switching wipes data.** Switching the selected variant of a discriminated union rewrites `model.value` to a fresh instance of the target variant's type. Use `useAsUnion`'s stash inside one mount, or persist before switching for longer-lived recovery. |80| 9 | **`AsAction` is a phantom field.** It renders a button that emits `action` from `<AsForm>` with `(name: string, data: TFormData)`. The phantom field carries `@ui.form.action 'id', 'label'` and no real data slot. |81| 10 | **`@ui.form.action` on a regular input renders an inline link.** On a non-phantom input it appears as a link-styled button in the field footer and emits the same `action` event; in `<AsWfForm>` it resolves via `@WfAction()`. See [actions-refs.md](references/actions-refs.md). |82| 11 | **`@ui.form.pushDown` renders a field below the submit button** in its own grid (same 12-col layout; `@ui.form.order` / `@ui.form.grid.*` apply). The default `AsAction` also reads `@ui.form.attr 'text'` (prefix before the link) and `'align'` (`left`/`center`/`right`, default `left`) — phantom `ui.action` fields only. All three carry into `<AsWfForm>` unchanged. See [actions-refs.md](references/actions-refs.md). |8384## Key imports8586```ts87// Tier 1 — primary (auto-imported by AsResolver)88import { AsForm, AsField, AsIterator, AsCollapsible } from "@atscript/vue-form";8990// Tier 2 — defaults (swap targets; subpath imports also available)91import {92 AsFieldShell,93 AsInput,94 AsNumber,95 AsDecimal,96 AsSelect,97 AsRadio,98 AsCheckbox,99 AsDate,100 AsDatetime,101 AsTime,102 AsParagraph,103 AsAction,104 AsObject,105 AsArray,106 AsUnion,107 AsTuple,108 AsRef,109 AsMultiSelect,110} from "@atscript/vue-form";111112// Composables113import {114 useAsForm,115 useAsFormPatch,116 useAsField,117 useAsState,118 useAsArray,119 useAsUnion,120 useAsTuple,121 useAsValueHelp,122 useAsDropdown,123 useAsOptionalAddFlow,124 useAsTriStateCheckbox,125 useAsDate,126 useAsDecimal,127 useAsNumber,128 useAsDualInput,129 useAsLocale,130 useAsPath,131 useAsTypeMap,132 useAsData,133 useAsErrorDismiss,134 useAsNestedSectionsStore,135 useAsDescendantErrorCounts,136 useAsExternalErrors,137 useAsUnionVariant,138} from "@atscript/vue-form";139140// Factories + providers141import {142 createDefaultTypes,143 createAsFormDef,144 formatIndexedLabelParts,145 provideAsLocale,146 provideAsNestedSectionsStore,147} from "@atscript/vue-form";148149// Types150import type {151 TAsComponentProps,152 TAsComponentEmits,153 TAsCollapsibleProps,154 TAsCollapsibleSlots,155 TAsChangeType,156 TAsTypeComponents,157 TAsUnionContext,158 TFormState,159 TFormRule,160 UseAsFieldOptions,161 UseAsFieldReturn,162 UseAsFormOptions,163 UseAsFormReturn,164} from "@atscript/vue-form";165```166167## References — load only what's needed168169| Domain | File | When |170| -------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |171| First contact | [getting-started.md](references/getting-started.md) | Minimal mount, `createAsFormDef`, `:types` / `:components` / `:errors` props, the submit/action emit contract |172| `<AsForm>` reference | [forms.md](references/forms.md) | AsForm/AsField/AsIterator props/emits/slots, default type map, validation (`@expect.*`, `@meta.required`, `firstValidation` strategies, fresh-fields suppression, external errors, `__form` banner) |173| Structural fields | [structural-fields.md](references/structural-fields.md) | Arrays (scalar/object/nested, union-item), nested objects (collapsible, path nesting, `provideAsNestedSectionsStore`), discriminated unions (variant detection, `useAsUnion` stash), tuples (`useAsTuple.fillMissing`, positional labels) |174| Dynamic fields | [dynamic-fields.md](references/dynamic-fields.md) | `@atscript/ui-fns`: `installDynamicResolver()`, `@ui.form.fn.*` (label, hidden, disabled, readonly, options, value, attr, classes, styles, title, submit.text, submit.disabled), `@ui.form.validate`, `TFnScope` (`v` / `data` / `context` / `entry`), security model, FNPool caching |175| Customization | [customization.md](references/customization.md) | Three-level override: `:types` (built-in id swap) → `:components` (custom name + `@ui.form.component`) → `AsFieldShell` wrap → fully custom root via `useAsForm`. The **`<AsForm>` slot-props bag** (canonical key list spread onto every slot) + per-slot extras + the empty-slot-≠-hidden rule. `TAsComponentProps` contract for custom components. **Container renderers** replacing an object field's section chrome (`useAsVisibleFields`, `useAsFieldScope`, `useAsOptionalField`, `useAsLevel`/`provideAsNestedLevel`, `AsIterator` `:fields`/`:path-prefix`/`:levels`, delegate arrays/unions to `<AsField>`). Locale providers (`provideAsLocale`, currency, units). |176| Actions + refs | [actions-refs.md](references/actions-refs.md) | `@ui.form.action` + `AsAction` (single + multi-action forms, submit text, conditional disable, `@ui.form.pushDown` below-submit placement, alt-action `text`/`align` via `@ui.form.attr`), `@db.rel.FK` + `AsRef` value-help (`@db.http.path`, `@ui.dict.*` on target, `clientFactory` for auth headers, `ValueHelpClient` flow) |177| Change tracking | [form-change-tracking.md](references/form-change-tracking.md) | Tracking form edits / dirty state / `track-changes` prop / `useAsFormPatch`; building an `@atscript/db` patch from a form for `table.updateOne`; gating Save on `isDirty`; per-field dirty to mark changed fields (`isPathDirty`/`isDirtyPath`/`useAsField().isDirty`/`data-dirty`); `getPatch`/`getChanges`/`rebase`; `rebaseOnto` 3-way merge (fold fresh server data into a dirty form) + `buildFormRebase`/`applyFormChanges`; nested replace-vs-`merge`; keyed-array `$update`/`$insert`/`$remove`; `$cas` OCC from `@db.column.version` |178| Aooth components | [aooth-components.md](references/aooth-components.md) | Reaching for the prebuilt `@atscript/vue-aooth` field components — `AsConsentArray`, `AsPasswordRules`, `AsQrCode`, `AsCopy`, `AsSsoProviders` — or one-click SSO/social-login provider buttons that fire a form action, or building a phantom display field driven by workflow context (`ui.paragraph` + `@ui.form.fn.value` + `@wf.context.pass`) |179| Collapsible sections | [collapsible-sections.md](references/collapsible-sections.md) | Wrapping a custom component in section chrome / adding a header-row action to a section / using `<AsCollapsible>` directly / full-bleed section dividers to a padded card or enclosing island (`--as-inset`) |180181## OCC-enabled edit forms182183For tables annotated with `@db.column.version`, the server returns `meta.versionColumn` and auto-handles compare-and-set on update. Wire the form so the version field doesn't render as an input but the value still rides the wire:184185```ts186import { createFormDef } from "@atscript/ui";187import { deserializeAnnotatedType } from "@atscript/typescript/utils";188import { VersionMismatchError } from "@atscript/db-client";189190const meta = await client.meta();191const formDef = createFormDef(deserializeAnnotatedType(meta.type), {192 versionColumn: meta.versionColumn,193});194195async function onSubmit(data: unknown) {196 try {197 await client.update(data as never);198 } catch (e) {199 if (e instanceof VersionMismatchError) {200 showError(`Row changed (current version: ${e.currentVersion}). Reload to continue.`);201 } else {202 throw e;203 }204 }205}206```207208The version prop is excluded from `def.fields[]` (so `<AsForm>` doesn't paint it) but stays in `flatMap` and the underlying form data, so the PATCH body preserves it for the server's `$cas` lift. `createTableDef` does the symmetric thing on the table side — the version column never appears in column / filter / sort dialogs. See the `atscript-db` skill (OCC reference) for the server-side mechanics (`@db.column.version`, `$cas`, `VersionMismatchError`).209210## Customization211212Forms expose three swap mechanisms, layered on the tier model:213214- **Tier 1** — `<AsForm>`, `<AsField>`, `<AsIterator>` are the integration surface. Configure them via props and slots; if you need a fully custom shell, build one with `useAsForm` / `useAsField` directly.215- **Tier 2** — the default field components (`AsInput`, `AsSelect`, `AsRadio`, `AsCheckbox`, `AsDate`, `AsParagraph`, `AsAction`, `AsObject`, `AsArray`, `AsUnion`, `AsTuple`, `AsRef`, `AsMultiSelect` — see "Key imports"). These are what you swap.216- **Tier 3** — internal composition helpers. Not directly tagged or swapped; their style classes ride with whichever defaults import them.217218### Swap a built-in renderer (`:types`)219220`:types` maps built-in renderer ids (per invariant 1: `text`, `select`, `date`, …) to a component. Useful when you want every field of a given built-in type to render with your design system's input:221222```vue223<script setup lang="ts">224import { createDefaultTypes } from "@atscript/vue-form";225import MyTextInput from "./MyTextInput.vue";226227const types = { ...createDefaultTypes(), text: MyTextInput };228</script>229230<template>231 <AsForm :def="def" :form-data="formData" :types="types" />232</template>233```234235### Swap a specific field (`:components` + `@ui.form.component`)236237`:components` looks up by a custom name the `.as` type opts into. Use this when you want one field — not every field of that type — to render with a custom widget (e.g. a country picker that's still a `string` to the rest of the system):238239```atscript240export interface Profile {241 @meta.label 'Country'242 @ui.form.component 'country-picker'243 country: string244}245```246247```vue248<script setup lang="ts">249import CountryPicker from "./CountryPicker.vue";250const components = { "country-picker": CountryPicker };251</script>252253<template>254 <AsForm :def="def" :form-data="formData" :components="components" />255</template>256```257258`:components` takes precedence over `:types` (invariant 4). The `:types` map stays reserved for built-in ids — point custom names at `:components` and you keep the renderer ids available for genuine type-level overrides.259260### Wrap with `AsFieldShell`261262When the framing (label, hint, error, required marker, description) is already what you want but the input itself isn't, wrap your custom input in `AsFieldShell` rather than re-implementing the chrome:263264```vue265<script setup lang="ts">266import { AsFieldShell } from "@atscript/vue-form";267import type { TAsComponentProps } from "@atscript/vue-form";268defineProps<TAsComponentProps<string>>();269</script>270271<template>272 <AsFieldShell v-bind="$props">273 <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />274 </AsFieldShell>275</template>276```277278If instead you render a **bare root** (no `AsFieldShell` — e.g. a section or media block that doesn't want the label/error chrome), you must bind `:class="props.class"` (and `:style="props.style"`) on that root yourself. `class`/`style` are _declared_ props on `TAsComponentProps`, so they arrive as `props.class` — not `$attrs` — and are not auto-applied to the root; `AsFieldShell` does this for you. Omit it and the field loses its `@ui.form.grid.colSpan`/`.rowSpan` placement (renders at the wrong width). Binding `props.class` is sufficient at every span — the default full-width and any `colSpan` override both ride on it, so you don't add `as-grid-item` or `col-span-*` yourself.279280Full contract for custom components (`TAsComponentProps`, `TAsComponentEmits`, locale providers) lives in [customization.md](references/customization.md).281282### Style consequence283284If you swap `AsSelect` for a custom dropdown, the `as-select-*` shortcuts the default tagged drop out of your bundle automatically. Keep the default when its styling already fits your design system; for granular opt-out, see `atscript-ui-styles` (`allShortcuts` super-merge, swap in a narrower subset).285286### In-tree examples287288`@atscript/vue-aooth` ships five production examples of the custom-component and phantom-field patterns — `AsConsentArray`, `AsPasswordRules`, `AsQrCode`, `AsCopy`, `AsSsoProviders`. They demonstrate `useAsField` inside a component, re-using `compileFieldFn` from `@atscript/ui-fns` for fn-string arrays, the phantom (`ui.paragraph` + `@ui.form.fn.value` + `@wf.context.pass`) display pattern, and — via `AsSsoProviders` — **emitting a form action from a custom component** (one-click: select + fire `@ui.form.action`). See [aooth-components.md](references/aooth-components.md).289290## See also291292Reference docs: https://ui.atscript.dev/forms/. Source: https://github.com/moostjs/atscript-ui.