Forms

Schema-driven Vue forms with useTamanForm, codecs, and progressive validation — from @vinicunca/taman-ui/form.

Adapted from the package's src/form/README.md. The web app wraps this in apps/web/src/adapter/form.ts (localized required / selectRequired rules and a default calendarDateTimeCodec).

Mental model

useTamanForm(options)
  -> Form       Vue component rendered in the template
  -> formApi    controller for values, validation, submission, and schema changes
  • Raw values — what field components use (CalendarDate, etc.).
  • Submit values — what getValues() and handleSubmit see (ISO strings after a codec).

Without a codec, both shapes are the same.

Setup

Register named rules once at startup (setupTamanForm). A rule returns true or an error string:

import { isEmptyFormValue, setupTamanForm } from '@vinicunca/taman-ui/form';

setupTamanForm({
  rules: {
    required(value, _params, context) {
      return isEmptyFormValue(value)
        ? `${context.label ?? context.name} is required`
        : true;
    },
  },
});

Quick start

<script setup lang="ts">
import { useTamanForm, z } from '@vinicunca/taman-ui/form';

interface ProfileFormValues {
  email: string;
  name: string;
}

const [ProfileForm, formApi] = useTamanForm<ProfileFormValues>({
  handleSubmit(values) {
    console.log(values);
  },
  schema: [
    {
      component: 'Input',
      fieldName: 'name',
      label: 'Name',
      rules: z.string().min(1, 'Name is required'),
    },
    {
      component: 'Input',
      fieldName: 'email',
      label: 'Email',
      rules: z.email('Enter a valid email'),
    },
  ],
});

async function save() {
  const values = await formApi.validateAndSubmit();
  if (values) {
    console.log('Submitted', values);
  }
}
</script>

<template>
  <ProfileForm />
  <button
    type="button"
    @click="save"
  >
    Save
  </button>
</template>

Render <Form /> before awaiting API methods that need the runtime form. Those methods wait for the first mount; awaiting them without rendering waits forever.

z is a namespace re-export of Zod 4.

Generics

useTamanForm<TFormValues, TComponentType, TComponentPropsMap, TSubmitValues>(options);

The app adapter fixes component types from adapter/components.ts and only asks you for TFormValues / TSubmitValues.

Options

OptionDefaultPurpose
schema[]Fields and groups
commonConfig{}Shared field fallbacks
wrapperClassgrid-cols-1Form grid
compactfalseNo reserved message space
disabledfalseDisables fields and default actions
showDefaultActionstrueSubmit/reset
actionLayoutrowEndinline | newLine | rowEnd
actionPositionrightleft | center | right
submitOnEnterfalse
submitOnChangefalseDebounced by changeDebouncedTime (300 ms)
scrollToFirstErrorfalse
codecnoneRaw ↔ submit
handleSubmit(values, rawValues)
handleResetReplaces default reset; call formApi.reset() yourself if you still want a clear
handleValuesChange(rawValues, changedFields, getFormattedValues) — format lazily

commonConfig.disabled disables fields only.

Schema

Field names support nested paths (profile.email) and arrays (contacts[0].name). Wrap [profile.email] when the dots are literal.

PropertyPurpose
componentBuilt-in name, registered name, or Vue component
fieldNameValue path
defaultValueInitial raw value
label / description / help / suffixCopy
componentPropsObject or (ctx) => props
formFieldPropsvalidateOn, validators, asyncDebounceMs
rulesNamed rule, Zod schema, null, or another rule name
dependenciesRecompute when triggerFields change
hide / disabled / collapsed / collapsibleVisibility
formItemClassGrid item classes (cols-span-2, …)

Dependencies

{
  fieldName: 'companyName',
  component: 'Input',
  dependencies: {
    triggerFields: ['accountType'],
    resolve: ({ values }) => ({
      show: values.accountType === 'business',
      required: values.accountType === 'business',
      rules: values.accountType === 'business'
        ? z.string().min(1, 'Company name is required')
        : null,
    }),
  },
}

Groups (component: 'Group' or a schema array of fields) share wrapperClass. Array fields use component: 'Array' with fieldArray.

Validation

Default policy is progressive: no error while typing → blur or submit reveals → further changes revalidate.

formFieldProps: {
  validateOn: ['blur'], // or ['change'], or both
  asyncDebounceMs: 400,
  validators: {
    onDynamicAsync: async ({ value }: { value: string }) => {
      const available = await checkUsername(value);
      return available ? undefined : 'Username is already used';
    },
  },
}

Async work keeps the field (and default submit) busy. Stale results are dropped on reset / clearValidation.

formApi

Methods that need the runtime wait until <Form /> is mounted.

GoalMethod
One fieldsetFieldValue(name, value, shouldValidate?)
Patch, keep dirty/errorssetValues(patch, filterFields?, shouldValidate?)
New recordreset({ values }) or reset()
Load API-shaped valuessetSubmitValues(values) (requires a codec)
Raw / encodedgetRawValues() / getValues() / getValueSnapshot()
Validatevalidate(), validateField(name), isFieldValid(name)
Server errorsetFieldError(name, message?)
ClearclearValidation(names?)
SubmitvalidateAndSubmit() (preferred) or submit(event?)

setValues deep-merges objects, replaces arrays, and by default drops paths not in the schema. It is for patches in the same session, not for opening a different record in a reused drawer — use reset({ values }) for that.

validateAndSubmit() validates once, returns undefined when invalid, otherwise calls handleSubmit(values, rawValues) and returns formatted values.

updateSchema / setState({ schema }) change fields at runtime. formApi.form is the underlying TanStack Form instance for selectors (useStore) and low-level actions.

Slots

SlotUse
defaultFull custom layout (built-in actions are omitted)
#<fieldName>Replace one field (componentField, error, formApi, values, …)
submit-before / reset-beforeExtra action-area content
expand-before / expand-afterCollapse control

Codecs

const codec: FormCodec<FilterFormValues, FilterSubmitValues> = {
  decode(values) { /* API → UI */ },
  encode(values) { /* UI → API */ },
};

Exports: calendarDateCodec, calendarDateTimeCodec, timeCodec, createCalendarDateCodec, createCalendarDateTimeCodec, createTimeCodec. Failures throw FormCodecError with phase: 'encode' | 'decode'.

The web adapter default-assigns calendarDateTimeCodec when you omit codec.