Skip to content

Form ​

A form component designed for validation and handling submissions.

Usage ​

Use the Form component to validate form data using schema libraries such as Valibot, Zod, Yup, Joi, Superstruct or your own validation logic.

It works with the FormField component to display error messages around form elements automatically.

Schema Validation ​

It requires two props:

  • state - a reactive object holding the form's state.
  • schema - a schema object from a validation library like Valibot, Zod, Yup, Joi or Superstruct.

WARNING

No validation library is included by default, ensure you install the one you need.

Errors are reported directly to the FormField component based on the name or error-pattern prop. This means the validation rules defined for the email attribute in your schema will be applied to <FormField name="email">.

Nested validation rules are handled using dot notation. For example, a rule like { user: z.object({ email: z.string() }) } will be applied to <FormField name="user.email">.

Custom Validation ​

Use the validate prop to apply your own validation logic.

The validation function must return a list of errors with the following attributes:

  • message - the error message to display.
  • name - the name of the FormField to send the error to.

INFO

It can be used alongside the schema prop to handle complex use cases.

Details
vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { FormError, FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import Cross30Icon from '@bitrix24/b24icons-vue/actions/Cross30Icon'
import Shining2Icon from '@bitrix24/b24icons-vue/main/Shining2Icon'

const isShowResult = ref(false)
const autoResultSeconds = ref(20)

interface State {
  email: string | undefined
  password: string | undefined
  tos: boolean | undefined
}

const state = reactive<State>({
  email: undefined,
  password: undefined,
  tos: undefined
})

const validate = (state: any): FormError[] => {
  const errors = []
  if (!state.email) errors.push({ name: 'email', message: 'Required' })

  if (!state.password) errors.push({ name: 'password', message: 'Required' })
  else if (state?.password.length < 8) errors.push({ name: 'password', message: 'Minimum 8 characters' })

  if (!state.tos) errors.push({ name: 'tos', message: 'Required' })
  return errors
}

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<any>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data, state)
  isShowResult.value = true
}

function resetState() {
  state.email = undefined
  state.password = undefined
  state.tos = undefined

  isShowResult.value = false
}

function fillState() {
  state.email = 'john@lennon.com'
  state.password = '12345678'
  state.tos = true
}
</script>

<template>
  <div class="flex flex-col gap-4">
    <B24Advice v-if="isShowResult" :avatar="{ src: `${$attrs?.base}/avatar/employee.png` }">
      <div class="flex flex-row items-start justify-between gap-2">
        <div>The form has been submitted.</div>
        <div class="shrink-0 relative size-6 group">
          <B24Countdown
            as="div"
            class="size-full absolute inset-x-0 inset-y-0 z-30 group-hover:z-10 group-hover:opacity-40"
            :seconds="autoResultSeconds"
            use-circle
            size="sm"
            @end="resetState"
            @click="resetState"
          />
          <Cross30Icon
            class="cursor-pointer size-full opacity-0 group-hover:opacity-100 text-base-500 dark:text-base-600 group-hover:text-base-900 dark:group-hover:text-base-100 absolute inset-x-0 inset-y-0 z-20"
            @click="resetState"
          />
        </div>
      </div>
    </B24Advice>
    <B24Form
      v-else
      :validate="validate"
      :state="state"
      class="space-y-4"
      @submit="onSubmit"
    >
      <B24FormField label="Email" name="email" help="Please enter a valid email address.">
        <B24Input v-model="state.email" />
      </B24FormField>

      <B24FormField label="Password" name="password" help="Minimum password length is 8 characters">
        <B24Input v-model="state.password" type="password" />
      </B24FormField>

      <B24FormField name="tos">
        <B24Checkbox v-model="state.tos">
          <template #label>
            You confirm that you accept the <B24Link active target="_blank" to="https://www.bitrix24.eu/terms/?utm_source=%40bitrix24%2Fb24ui-nuxt&utm_medium=form&utm_campaign=playground">
              Terms of Service
            </B24Link> and the <B24Link active target="_blank" to="https://www.bitrix24.eu/privacy/?utm_source=%40bitrix24%2Fb24ui-nuxt&utm_medium=form&utm_campaign=playground">
              Privacy Policy
            </B24Link>.
          </template>
        </B24Checkbox>
      </B24FormField>

      <B24Separator class="mt-6 mb-3" />

      <div class="flex flex-row gap-4 items-center justify-between">
        <B24Button type="submit" label="Submit" color="success" />
        <B24Button
          type="button"
          class="text-base-master/10 dark:text-base-100/20"
          color="link"
          :icon="Shining2Icon"
          @click="fillState"
        />
      </div>
    </B24Form>
  </div>
</template>

Input Events ​

The Form component automatically triggers validation when an input emits an input, change, or blur event.

  • Validation on input occurs as you type.
  • Validation on change occurs when you commit to a value.
  • Validation on blur happens when an input loses focus.

You can control when validation happens this using the validate-on prop.

TIP

You can use the useFormField composable to implement this inside your own components.

Details
vue
<script setup lang="ts">
import { reactive, ref, useTemplateRef } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import SuccessIcon from '@bitrix24/b24icons-vue/button/SuccessIcon'
import Cross30Icon from '@bitrix24/b24icons-vue/actions/Cross30Icon'
import Shining2Icon from '@bitrix24/b24icons-vue/main/Shining2Icon'

defineOptions({ inheritAttrs: false })

const schema = z.object({
  input: z.string().min(10),
  inputNumber: z.number().min(10),
  inputMenu: z.any().refine(option => option?.value === 'option-2', {
    message: 'Select Option 2'
  }),
  inputMenuMultiple: z.any().refine(values => !!values?.find((option: any) => option.value === 'option-2'), {
    message: 'Include Option 2'
  }),
  textarea: z.string().min(10),
  select: z.string().refine(value => value === 'option-2', {
    message: 'Select Option 2'
  }),
  selectMenu: z.any().refine(option => option?.value === 'option-2', {
    message: 'Select Option 2'
  }),
  selectMenuMultiple: z.any().refine(values => !!values?.find((option: any) => option.value === 'option-2'), {
    message: 'Include Option 2'
  }),
  switch: z.boolean().refine(value => value === true, {
    message: 'Toggle me'
  }),
  checkbox: z.boolean().refine(value => value === true, {
    message: 'Check me'
  }),
  radioGroup: z.string().refine(value => value === 'option-2', {
    message: 'Select Option 2'
  }),
  range: z.number().max(20, { message: 'Must be less than 20' })
})

type Schema = z.input<typeof schema>

const isShowResult = ref(false)
const autoResultSeconds = ref(20)

const state = reactive<Partial<Schema>>({})

const form = useTemplateRef('form')

const items = [
  { label: 'Option 1', value: 'option-1' },
  { label: 'Option 2', value: 'option-2' },
  { label: 'Option 3', value: 'option-3' }
]

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<any>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data, state)
  isShowResult.value = true
}

function resetState() {
  state.input = undefined
  state.inputNumber = undefined
  state.inputMenu = undefined
  state.inputMenuMultiple = undefined
  state.textarea = undefined
  state.select = undefined
  state.selectMenu = undefined
  state.selectMenuMultiple = undefined
  state.switch = undefined
  state.checkbox = undefined
  state.radioGroup = undefined
  state.range = undefined

  isShowResult.value = false
}

function fillState() {
  state.input = 'john john john'
  state.inputNumber = 11
  state.inputMenu = { label: 'Option 2', value: 'option-2' }
  state.inputMenuMultiple = [{ label: 'Option 2', value: 'option-2' }]
  state.textarea = 'john john john john john'
  state.select = 'option-2'
  state.selectMenu = { label: 'Option 2', value: 'option-2' }
  state.selectMenuMultiple = [{ label: 'Option 2', value: 'option-2' }]
  state.switch = true
  state.checkbox = true
  state.radioGroup = 'option-2'
  state.range = 15
}
</script>

<template>
  <B24Alert
    v-if="isShowResult"
    :icon="SuccessIcon"
    description="The form has been submitted."
    size="sm"
    color="success"
  >
    <template #description>
      <div class="flex flex-row items-center justify-between gap-2">
        <div>The form has been submitted.</div>
        <div class="shrink-0 relative size-6 group">
          <B24Countdown
            as="div"
            class="size-full absolute inset-x-0 inset-y-0 z-30 group-hover:z-10 group-hover:opacity-40"
            :seconds="autoResultSeconds"
            use-circle
            size="sm"
            @end="resetState"
            @click="resetState"
          />
          <Cross30Icon
            class="cursor-pointer size-full opacity-0 group-hover:opacity-100 text-base-500 dark:text-base-600 group-hover:text-base-900 dark:group-hover:text-base-900 absolute inset-x-0 inset-y-0 z-20"
            @click="resetState"
          />
        </div>
      </div>
    </template>
  </B24Alert>
  <B24Form
    v-show="!isShowResult"
    v-bind="$attrs"
    ref="form"
    :state="state"
    :schema="schema"
    class="space-y-6"
    @submit="onSubmit"
  >
    <B24FormField label="Input" name="input">
      <B24Input v-model="state.input" placeholder="john@lennon.com" class="w-full" />
    </B24FormField>

    <B24FormField name="switch" label="Switch">
      <B24Switch v-model="state.switch" label="Switch me" />
    </B24FormField>

    <B24FormField name="checkbox" label="Checkbox">
      <B24Checkbox v-model="state.checkbox" label="Check me" />
    </B24FormField>

    <B24FormField name="range" label="Range">
      <B24Range v-model="state.range" />
    </B24FormField>

    <B24FormField name="select" label="Select">
      <B24Select v-model="state.select" :items="items" class="w-full" />
    </B24FormField>

    <B24FormField name="selectMenu" label="Select Menu">
      <B24SelectMenu v-model="state.selectMenu" :items="items" class="w-full" />
    </B24FormField>

    <B24FormField name="selectMenuMultiple" label="Select Menu (Multiple)">
      <B24SelectMenu v-model="state.selectMenuMultiple" multiple :items="items" class="w-full" />
    </B24FormField>

    <B24FormField name="inputMenu" label="Input Menu">
      <B24InputMenu v-model="state.inputMenu" :items="items" class="w-full" />
    </B24FormField>

    <B24FormField name="inputMenuMultiple" label="Input Menu (Multiple)">
      <B24InputMenu v-model="state.inputMenuMultiple" multiple placeholder="Select..." :items="items" class="w-full" />
    </B24FormField>

    <B24FormField name="inputNumber" label="Input Number">
      <B24InputNumber v-model.number="state.inputNumber" class="w-full" />
    </B24FormField>

    <B24FormField label="Textarea" name="textarea">
      <B24Textarea v-model="state.textarea" class="w-full" />
    </B24FormField>

    <B24FormField name="radioGroup">
      <B24RadioGroup v-model="state.radioGroup" legend="Radio group" :items="items" />
    </B24FormField>

    <B24Separator class="mt-6 mb-3" />
    <div class="flex flex-row gap-4 items-center justify-between">
      <div class="flex gap-2">
        <B24Button type="submit" label="Submit" color="success" :disabled="!!$attrs?.disabled" />
        <B24Button label="Clear" color="link" :disabled="!!$attrs?.disabled" @click="form?.clear(); resetState()" />
      </div>

      <B24Button
        type="button"
        class="text-base-master/10 dark:text-base-100/20"
        color="link"
        :icon="Shining2Icon"
        :disabled="!!$attrs?.disabled"
        @click="fillState"
      />
    </div>
  </B24Form>
</template>

Error Event ​

You can listen to the @error event to handle errors. This event is triggered when the form is submitted and contains an array of FormError objects with the following fields:

  • id - the input's id.
  • name - the name of the FormField
  • message - the error message to display.

Here's an example that focuses the first input element with an error after the form is submitted:

Details
vue
<script setup lang="ts">
import { reactive } from 'vue'
import type { FormError, FormErrorEvent, FormSubmitEvent } from '@bitrix24/b24ui-nuxt'

const state = reactive({
  email: undefined,
  password: undefined
})

const validate = (state: any): FormError[] => {
  const errors = []
  if (!state.email) errors.push({ name: 'email', message: 'Required' })
  if (!state.password) errors.push({ name: 'password', message: 'Required' })
  return errors
}

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<any>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data)
}

async function onError(event: FormErrorEvent) {
  if (event?.errors?.[0]?.id) {
    const element = document.getElementById(event.errors[0].id)
    element?.focus()
    element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
  }
}
</script>

<template>
  <div class="flex flex-col gap-4">
    <B24Form
      :validate="validate"
      :state="state"
      class="space-y-4"
      @submit="onSubmit"
      @error="onError"
    >
      <B24FormField label="Email" name="email">
        <B24Input v-model="state.email" />
      </B24FormField>

      <B24FormField label="Password" name="password">
        <B24Input v-model="state.password" type="password" />
      </B24FormField>

      <B24Separator class="mt-6 mb-3" />

      <B24Button type="submit" color="success">
        Submit
      </B24Button>
    </B24Form>
  </div>
</template>

Nesting Forms ​

Nesting form components allows you to manage complex data structures, such as lists or conditional fields, more efficiently.

For example, it can be used to dynamically add fields based on user's input:

Details
vue
<script setup lang="ts">
/**
 * @memo You should use `state` to get all the form input values.
 */
import { reactive, ref } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import SuccessIcon from '@bitrix24/b24icons-vue/button/SuccessIcon'
import Cross30Icon from '@bitrix24/b24icons-vue/actions/Cross30Icon'
import Shining2Icon from '@bitrix24/b24icons-vue/main/Shining2Icon'

const schema = z.object({
  name: z.string().min(2),
  news: z.boolean().default(false)
})

type Schema = z.output<typeof schema>

const nestedSchema = z.object({
  email: z.string().email()
})

type NestedSchema = z.output<typeof nestedSchema>

const isShowResult = ref(false)
const autoResultSeconds = ref(20)

const state = reactive<Partial<Schema & NestedSchema>>({ })

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<any>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data, state)
  isShowResult.value = true
}

function resetState() {
  state.name = undefined
  state.news = undefined
  state.email = undefined

  isShowResult.value = false
}

function fillState() {
  state.name = 'john'
  state.news = true
  state.email = 'john@lennon.com'
}
</script>

<template>
  <div class="flex flex-col gap-4">
    <B24Alert
      v-if="isShowResult"
      :icon="SuccessIcon"
      description="The form has been submitted."
      size="sm"
      color="success"
    >
      <template #description>
        <div class="flex flex-row items-center justify-between gap-2">
          <div>The form has been submitted.</div>
          <div class="shrink-0 relative size-6 group">
            <B24Countdown
              as="div"
              class="size-full absolute inset-x-0 inset-y-0 z-30 group-hover:z-10 group-hover:opacity-40"
              :seconds="autoResultSeconds"
              use-circle
              size="sm"
              @end="resetState"
              @click="resetState"
            />
            <Cross30Icon
              class="cursor-pointer size-full opacity-0 group-hover:opacity-100 text-base-500 dark:text-base-600 group-hover:text-base-900 dark:group-hover:text-base-900 absolute inset-x-0 inset-y-0 z-20"
              @click="resetState"
            />
          </div>
        </div>
      </template>
    </B24Alert>
    <B24Form
      v-else
      :state="state"
      :schema="schema"
      class="space-y-4"
      @submit="onSubmit"
    >
      <B24FormField label="Name" name="name">
        <B24Input v-model="state.name" placeholder="John Lennon" />
      </B24FormField>

      <div>
        <B24Checkbox v-model="state.news" name="news" label="Register to our newsletter" @update:model-value="state.email = undefined" />
      </div>

      <B24Form v-if="state.news" :state="state" :schema="nestedSchema">
        <B24FormField label="Email" name="email">
          <B24Input v-model="state.email" />
        </B24FormField>
      </B24Form>

      <B24Separator class="mt-6 mb-3" />

      <div class="flex flex-row gap-4 items-center justify-between">
        <B24Button type="submit" label="Submit" color="success" />
        <B24Button
          type="button"
          class="text-base-master/10 dark:text-base-100/20"
          color="link"
          :icon="Shining2Icon"
          @click="fillState"
        />
      </div>
    </B24Form>
  </div>
</template>

Or to validate list inputs:

Details
vue
<script setup lang="ts">
/**
 * @memo You should use `state` to get all the form input values.
 */
import { reactive, ref } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import SuccessIcon from '@bitrix24/b24icons-vue/button/SuccessIcon'
import Cross30Icon from '@bitrix24/b24icons-vue/actions/Cross30Icon'
import Shining2Icon from '@bitrix24/b24icons-vue/main/Shining2Icon'

const schema = z.object({
  customer: z.string().min(2)
})

type Schema = z.output<typeof schema>

const itemSchema = z.object({
  description: z.string().min(1),
  price: z.number().min(0.01)
})

type ItemSchema = z.output<typeof itemSchema>

const isShowResult = ref(false)
const autoResultSeconds = ref(20)

const state = reactive<Partial<Schema & { items: Partial<ItemSchema>[] }>>({
  items: [{
    description: '',
    price: 0.01
  }]
})

function addItem() {
  if (!state.items) {
    state.items = []
  }
  state.items.push({
    description: '',
    price: 0.01
  })
}

function removeItem() {
  if (state.items) {
    state.items.pop()
  }
}

function resetState() {
  state.customer = undefined
  state.items = [{
    description: '',
    price: 0.01
  }]

  isShowResult.value = false
}

function fillState() {
  state.customer = 'john'
  state.items = []
  state.items.push({
    description: 'product 1',
    price: 10.00
  })
  state.items.push({
    description: 'product 2',
    price: 20.31
  })
}
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<any>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data, state)
  isShowResult.value = true
}
</script>

<template>
  <B24Alert
    v-if="isShowResult"
    :icon="SuccessIcon"
    description="The form has been submitted."
    size="sm"
    color="success"
  >
    <template #description>
      <div class="flex flex-row items-center justify-between gap-2">
        <div>The form has been submitted.</div>
        <div class="shrink-0 relative size-6 group">
          <B24Countdown
            as="div"
            class="size-full absolute inset-x-0 inset-y-0 z-30 group-hover:z-10 group-hover:opacity-40"
            :seconds="autoResultSeconds"
            use-circle
            size="sm"
            @end="resetState"
            @click="resetState"
          />
          <Cross30Icon
            class="cursor-pointer size-full opacity-0 group-hover:opacity-100 text-base-500 dark:text-base-600 group-hover:text-base-900 dark:group-hover:text-base-900 absolute inset-x-0 inset-y-0 z-20"
            @click="resetState"
          />
        </div>
      </div>
    </template>
  </B24Alert>
  <B24Form
    v-else
    :state="state"
    :schema="schema"
    class="space-y-4"
    @submit="onSubmit"
  >
    <B24FormField label="Customer" name="customer">
      <B24Input v-model="state.customer" placeholder="Wonka Industries" />
    </B24FormField>
    <div>
      <B24Form
        v-for="(item, count) in state.items"
        :key="count"
        :state="item"
        :schema="itemSchema"
        class="flex gap-1.5"
      >
        <B24FormField :label="!count ? 'Description' : undefined" name="description" class="w-34">
          <B24Input v-model="item.description" />
        </B24FormField>
        <B24FormField :label="!count ? 'Price' : undefined" name="price" class="w-30">
          <B24InputNumber v-model.number="item.price" :min="0.01" :max="999" :step="0.01" />
        </B24FormField>
      </B24Form>
    </div>
    <div class="flex gap-2">
      <B24Button color="default" size="xs" @click="addItem()">
        Add Item
      </B24Button>

      <B24Button color="default" size="xs" @click="removeItem()">
        Remove Item
      </B24Button>
    </div>

    <B24Separator class="mt-6 mb-3" />

    <div class="flex flex-row gap-4 items-center justify-between">
      <B24Button type="submit" label="Submit" color="success" />
      <B24Button
        type="button"
        class="text-base-master/10 dark:text-base-100/20"
        color="link"
        :icon="Shining2Icon"
        @click="fillState"
      />
    </div>
  </B24Form>
</template>

API ​

Props ​

Prop Default Type
stateobject
idstring | number
schemaZodType<any, ZodTypeDef, any> | GenericSchema<unknown, unknown, BaseIssue<unknown>> | GenericSchemaAsync<unknown, unknown, BaseIssue<unknown>> | (input: unknown): SafeParseResult<any> | (input: unknown): Promise<SafeParseResult<any>> | Struct<any, any> | StandardSchemaV1<unknown, unknown> | ObjectSchema<object, AnyObject, any, ""> | AnySchema<object> | ArraySchema<object> | AlternativesSchema<object> | BinarySchema<object> | BooleanSchema<object> | DateSchema<object> | FunctionSchema<object> | NumberSchema<object> | ObjectSchema<object> | StringSchema<object> | LinkSchema<object> | SymbolSchema<object>
validate(state: object): FormError<string>[] | Promise<FormError<string>[]>
validateOnFormInputEvents[]
disabledboolean
validateOnInputDelay300number
transformtrueboolean

Slots ​

Slot Type
default{}

Emits ​

Event Type

Expose ​

You can access the typed component instance using useTemplateRef.

vue
<script setup lang="ts">
  const form = useTemplateRef('form')
</script>

<template>
  <B24Form ref="form" />
</template>

This will give you access to the following:

NameType
submit()Promise<void>

Triggers form submission.

validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean })Promise<T>

Triggers form validation. Will raise any errors unless opts.silent is set to true.

clear(path?: keyof T)void

Clears form errors associated with a specific path. If no path is provided, clears all form errors.

getErrors(path?: keyof T)FormError[]

Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.

setErrors(errors: FormError[], path?: keyof T)void

Sets form errors for a given path. If no path is provided, overrides all errors.

errorsRef<FormError[]>

A reference to the array containing validation errors. Use this to access or manipulate the error information.

disabledRef<boolean>
dirtyRef<boolean> true if at least one form field has been updated by the user.
dirtyFieldsDeepReadonly<Set<keyof T>> Tracks fields that have been modified by the user.
touchedFieldsDeepReadonly<Set<keyof T>> Tracks fields that the user interacted with.
blurredFieldsDeepReadonly<Set<keyof T>> Tracks fields blurred by the user.

Released under the MIT License.