UIPackage

Form

Vue form
Edit on GitHub

Zod-first form block built on TanStack Vue Form. Wires field labels, descriptions, error messages, and validation together; bind a field once and the rest is automatic.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/form.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/form

Examples

Props

Name Type / Values Default Required
form TanstackFormApi required
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

FieldMeta
interface FieldMeta {
  errors: Array<unknown>
  errorMap?: Record<string, Array<unknown> | undefined>
  isDirty: boolean
  isTouched: boolean
  isValid: boolean
}
FieldApi
interface FieldApi {
  name: string
  state: {
    value: unknown
    meta: FieldMeta
  }
  store: {
    subscribe: (cb: () => void) => () => void
  }
  handleChange: (v: unknown) => void
  handleBlur: () => void
}
FormFieldContext
interface FormFieldContext {
  name: string
  error: ComputedRef<string | undefined>
  valid: ComputedRef<boolean>
  isDirty: ComputedRef<boolean>
  isTouched: ComputedRef<boolean>
}
TanstackFormApi
interface TanstackFormApi {
  Field: unknown
  handleSubmit: () => void | Promise<void>
}

Dependencies

Files (15)

  • app/components/ui/form/Form.vue 0.6 kB
    <script lang="ts" setup>
    import type { HTMLAttributes } from 'vue'
    import { provide } from 'vue'
    import { cn } from '@/lib/utils'
    import { FORM_INSTANCE_INJECTION_KEY, type TanstackFormApi } from './injectionKeys'
    
    const props = defineProps<{
      form: TanstackFormApi
      class?: HTMLAttributes['class']
    }>()
    
    provide(FORM_INSTANCE_INJECTION_KEY, props.form)
    
    function onSubmit(e: Event) {
      e.preventDefault()
      e.stopPropagation()
      void props.form.handleSubmit()
    }
    </script>
    
    <template>
      <form data-uipkge data-slot="form" :class="cn(props.class)" @submit="onSubmit">
        <slot />
      </form>
    </template>
  • app/components/ui/form/FormActions.vue 0.7 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    const props = withDefaults(
      defineProps<{
        align?: 'left' | 'center' | 'right'
        gap?: 'sm' | 'md' | 'lg'
        class?: HTMLAttributes['class']
      }>(),
      {
        align: 'right',
        gap: 'md',
      },
    )
    
    const alignClasses = {
      left: 'justify-start',
      center: 'justify-center',
      right: 'justify-end',
    }
    
    const gapClasses = {
      sm: 'gap-2',
      md: 'gap-3',
      lg: 'gap-4',
    }
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="form-actions"
        :class="cn('flex flex-wrap items-center', alignClasses[align], gapClasses[gap], props.class)"
      >
        <slot />
      </div>
    </template>
  • app/components/ui/form/FormControl.vue 0.4 kB
    <script lang="ts" setup>
    import { Slot } from 'reka-ui'
    import { useFormField } from './useFormField'
    
    const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
    </script>
    
    <template>
      <Slot
        :id="formItemId"
        data-uipkge
        data-slot="form-control"
        :aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
        :aria-invalid="!!error"
      >
        <slot />
      </Slot>
    </template>
  • app/components/ui/form/FormDescription.vue 0.5 kB
    <script lang="ts" setup>
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { useFormField } from './useFormField'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
    }>()
    
    const { formDescriptionId } = useFormField()
    </script>
    
    <template>
      <p
        :id="formDescriptionId"
        data-uipkge
        data-slot="form-description"
        :class="cn('text-muted-foreground text-sm', props.class)"
      >
        <slot />
      </p>
    </template>
  • app/components/ui/form/FormField.vue 0.9 kB
    <script lang="ts" setup>
    import { computed, inject } from 'vue'
    import FormFieldInner from './FormFieldInner.vue'
    import { FORM_INSTANCE_INJECTION_KEY, type TanstackFormApi } from './injectionKeys'
    
    const props = defineProps<{
      form?: TanstackFormApi
      name: string
      validators?: Record<string, unknown>
    }>()
    
    const injected = inject(FORM_INSTANCE_INJECTION_KEY, null)
    
    const resolvedForm = computed<TanstackFormApi>(() => {
      const f = props.form ?? injected
      if (!f) throw new Error('<FormField> requires a `form` prop or to be nested inside <Form :form="...">')
      return f
    })
    </script>
    
    <template>
      <component :is="resolvedForm.Field" :name="props.name" :validators="props.validators">
        <template #default="{ field }">
          <FormFieldInner :field="field">
            <template #default="binding">
              <slot v-bind="binding" />
            </template>
          </FormFieldInner>
        </template>
      </component>
    </template>
  • app/components/ui/form/FormFieldInner.vue 2.4 kB
    <script lang="ts" setup>
    import { computed, onUnmounted, provide, shallowRef, watch } from 'vue'
    import { FORM_FIELD_INJECTION_KEY } from './injectionKeys'
    
    interface FieldMeta {
      errors: Array<unknown>
      errorMap?: Record<string, Array<unknown> | undefined>
      isDirty: boolean
      isTouched: boolean
      isValid: boolean
    }
    
    interface FieldApi {
      name: string
      state: {
        value: unknown
        meta: FieldMeta
      }
      store: {
        subscribe: (cb: () => void) => () => void
      }
      handleChange: (v: unknown) => void
      handleBlur: () => void
    }
    
    const props = defineProps<{ field: FieldApi }>()
    
    const snapshot = shallowRef({ value: props.field.state.value, meta: props.field.state.meta })
    let unsubscribe: (() => void) | null = null
    
    function refreshSnapshot() {
      snapshot.value = { value: props.field.state.value, meta: props.field.state.meta }
    }
    
    function subscribe(field: FieldApi) {
      unsubscribe?.()
      unsubscribe = field.store.subscribe(refreshSnapshot)
      refreshSnapshot()
    }
    
    subscribe(props.field)
    watch(
      () => props.field,
      (next) => subscribe(next),
    )
    onUnmounted(() => unsubscribe?.())
    
    function formatError(raw: unknown): string {
      if (typeof raw === 'string') return raw
      if (raw && typeof raw === 'object' && 'message' in raw) {
        return String((raw as { message: unknown }).message)
      }
      return String(raw)
    }
    
    const error = computed(() => {
      const meta = snapshot.value.meta
      const flat = meta.errors
      if (Array.isArray(flat) && flat.length > 0 && flat[0] != null) {
        return formatError(flat[0])
      }
      const map = meta.errorMap ?? {}
      for (const key of Object.keys(map)) {
        const arr = map[key]
        if (Array.isArray(arr) && arr.length > 0 && arr[0] != null) {
          return formatError(arr[0])
        }
      }
      return undefined
    })
    
    const valid = computed(() => snapshot.value.meta.isValid)
    const isDirty = computed(() => snapshot.value.meta.isDirty)
    const isTouched = computed(() => snapshot.value.meta.isTouched)
    
    provide(FORM_FIELD_INJECTION_KEY, {
      name: props.field.name,
      error,
      valid,
      isDirty,
      isTouched,
    })
    
    const componentField = computed(() => ({
      modelValue: snapshot.value.value,
      'onUpdate:modelValue': (v: unknown) => props.field.handleChange(v),
      onBlur: () => props.field.handleBlur(),
      name: props.field.name,
    }))
    </script>
    
    <template>
      <slot
        :field="props.field"
        :component-field="componentField"
        :error="error"
        :valid="valid"
        :is-dirty="isDirty"
        :is-touched="isTouched"
      />
    </template>
  • app/components/ui/form/FormItem.vue 2.1 kB
    <script lang="ts" setup>
    import type { HTMLAttributes } from 'vue'
    import { useId } from 'reka-ui'
    import { computed, provide } from 'vue'
    import { cn } from '@/lib/utils'
    import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
    import FormLabel from './FormLabel.vue'
    import type { FormStatus } from './types'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
      label?: string
      required?: boolean
      description?: string
      status?: FormStatus
      help?: string
      layout?: 'vertical' | 'horizontal'
      labelWidth?: string
    }>()
    
    const id = useId()
    provide(FORM_ITEM_INJECTION_KEY, id)
    
    const statusBorderClass = computed(() => {
      switch (props.status) {
        case 'error':
          return 'border-destructive focus-within:border-destructive focus-within:ring-destructive/20'
        case 'warning':
          return 'border-warning focus-within:border-warning focus-within:ring-warning/20'
        case 'success':
          return 'border-success focus-within:border-success focus-within:ring-success/20'
        default:
          return ''
      }
    })
    
    const isHorizontal = computed(() => props.layout === 'horizontal')
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="form-item"
        :class="
          cn(
            'grid gap-1.5',
            isHorizontal && 'grid-cols-[var(--label-width,140px)_1fr] items-start gap-x-4 gap-y-0',
            props.class,
          )
        "
        :style="labelWidth ? { '--label-width': labelWidth } : undefined"
      >
        <div v-if="label || $slots.label" class="flex items-center gap-1">
          <FormLabel v-if="label" :for="id">{{ label }}</FormLabel>
          <slot name="label" />
          <span v-if="required" class="text-destructive text-sm">*</span>
        </div>
    
        <div class="space-y-1">
          <slot />
          <p v-if="description" class="text-muted-foreground text-xs">{{ description }}</p>
          <p
            v-if="help"
            class="text-xs"
            :class="{
              'text-destructive': status === 'error',
              'text-warning': status === 'warning',
              'text-success': status === 'success',
              'text-muted-foreground': !status,
            }"
          >
            {{ help }}
          </p>
        </div>
      </div>
    </template>
  • app/components/ui/form/FormLabel.vue 0.6 kB
    <script lang="ts" setup>
    import type { LabelProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { Label } from '@/components/ui/label'
    import { useFormField } from './useFormField'
    
    const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
    
    const { error, formItemId } = useFormField()
    </script>
    
    <template>
      <Label
        data-uipkge
        data-slot="form-label"
        :data-error="!!error"
        :class="cn('data-[error=true]:text-destructive', props.class)"
        :for="formItemId"
      >
        <slot />
      </Label>
    </template>
  • app/components/ui/form/FormMessage.vue 0.5 kB
    <script lang="ts" setup>
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { useFormField } from './useFormField'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
    }>()
    
    const { error, formMessageId } = useFormField()
    </script>
    
    <template>
      <p
        v-if="error"
        :id="formMessageId"
        data-uipkge
        data-slot="form-message"
        :class="cn('text-destructive text-sm', props.class)"
      >
        {{ error }}
      </p>
    </template>
  • app/components/ui/form/FormSection.vue 0.9 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    const props = defineProps<{
      title?: string
      subtitle?: string
      description?: string
      divider?: boolean
      headingLevel?: 'h2' | 'h3' | 'h4' | 'h5'
      class?: HTMLAttributes['class']
    }>()
    </script>
    
    <template>
      <div data-uipkge data-slot="form-section" :class="cn('space-y-3', props.class)">
        <div v-if="divider || title || subtitle" :class="divider && 'border-t pt-4'">
          <div v-if="title || subtitle" class="space-y-1">
            <component :is="props.headingLevel ?? 'h4'" v-if="title" class="text-sm font-semibold">{{ title }}</component>
            <p v-if="subtitle" class="text-muted-foreground text-xs">{{ subtitle }}</p>
          </div>
        </div>
        <p v-if="description" class="text-muted-foreground text-xs">{{ description }}</p>
        <slot />
      </div>
    </template>
  • app/components/ui/form/FormStatus.vue 1.1 kB
    <script setup lang="ts">
    import type { HTMLAttributes, Component } from 'vue'
    import { CircleAlert, TriangleAlert, CircleCheck } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import type { FormStatus } from './types'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
      status: FormStatus
      message: string
    }>()
    
    const statusConfig: Record<FormStatus, { icon: Component; container: string }> = {
      error: {
        icon: CircleAlert,
        container: 'bg-destructive/10 text-destructive border-destructive/20',
      },
      warning: {
        icon: TriangleAlert,
        container: 'bg-warning/10 text-warning border-warning/30',
      },
      success: {
        icon: CircleCheck,
        container: 'bg-success/10 text-success border-success/30',
      },
    }
    </script>
    
    <template>
      <div
        v-if="status"
        data-uipkge
        data-slot="form-status"
        :class="
          cn('flex items-center gap-2 rounded-md border px-3 py-2 text-sm', statusConfig[status].container, props.class)
        "
      >
        <component :is="statusConfig[status].icon" class="size-4 shrink-0" />
        <span>{{ message }}</span>
      </div>
    </template>
  • app/components/ui/form/index.ts 0.8 kB
    export { default as Form } from './Form.vue'
    export { default as FormField } from './FormField.vue'
    export { default as FormControl } from './FormControl.vue'
    export { default as FormDescription } from './FormDescription.vue'
    export { default as FormItem } from './FormItem.vue'
    export { default as FormLabel } from './FormLabel.vue'
    export { default as FormMessage } from './FormMessage.vue'
    export { default as FormActions } from './FormActions.vue'
    export { default as FormSection } from './FormSection.vue'
    export { default as FormStatus } from './FormStatus.vue'
    export { useFormField } from './useFormField'
    export { FORM_ITEM_INJECTION_KEY, FORM_FIELD_INJECTION_KEY } from './injectionKeys'
    export type { FormFieldContext } from './injectionKeys'
    export type { FormStatus as FormStatusValue } from './types'
    export { useForm } from '@tanstack/vue-form'
  • app/components/ui/form/injectionKeys.ts 0.6 kB
    import type { ComputedRef, InjectionKey } from 'vue'
    
    export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string>
    
    export interface FormFieldContext {
      name: string
      error: ComputedRef<string | undefined>
      valid: ComputedRef<boolean>
      isDirty: ComputedRef<boolean>
      isTouched: ComputedRef<boolean>
    }
    
    export const FORM_FIELD_INJECTION_KEY = Symbol() as InjectionKey<FormFieldContext>
    
    export interface TanstackFormApi {
      Field: unknown
      handleSubmit: () => void | Promise<void>
    }
    
    export const FORM_INSTANCE_INJECTION_KEY = Symbol() as InjectionKey<TanstackFormApi>
  • app/components/ui/form/types.ts 0.1 kB
    export type FormStatus = 'error' | 'warning' | 'success' | null | undefined
  • app/components/ui/form/useFormField.ts 0.7 kB
    import { computed, inject } from 'vue'
    import { FORM_FIELD_INJECTION_KEY, FORM_ITEM_INJECTION_KEY } from './injectionKeys'
    
    export function useFormField() {
      const fieldContext = inject(FORM_FIELD_INJECTION_KEY)
      const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
    
      if (!fieldContext) throw new Error('useFormField should be used within <FormField>')
    
      const id = fieldItemContext
    
      return {
        id,
        name: computed(() => fieldContext.name),
        formItemId: `${id}-form-item`,
        formDescriptionId: `${id}-form-item-description`,
        formMessageId: `${id}-form-item-message`,
        valid: fieldContext.valid,
        isDirty: fieldContext.isDirty,
        isTouched: fieldContext.isTouched,
        error: fieldContext.error,
      }
    }

Raw manifest: https://uipkge.dev/r/vue/form.json