Usage
Use the Form component to validate form data using any validation library supporting Standard Schema such as Valibot, Zod, Regle, Yup, Joi or 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- any Standard Schema or Superstruct.
<script setup lang="ts">
import * as v from 'valibot'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = v.object({
email: v.pipe(v.string(), v.email('Invalid email')),
password: v.pipe(v.string(), v.minLength(8, 'Must be at least 8 characters'))
})
type Schema = v.InferOutput<typeof schema>
const state = reactive({
email: '',
password: ''
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import * as v from 'valibot'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = v.object({
email: v.pipe(v.string(), v.email('Invalid email')),
password: v.pipe(v.string(), v.minLength(8, 'Must be at least 8 characters'))
})
type Schema = v.InferOutput<typeof schema>
const state = reactive({
email: '',
password: ''
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = z.object({
email: z.email('Invalid email'),
password: z.string('Password is required').min(8, 'Must be at least 8 characters')
})
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = z.object({
email: z.email('Invalid email'),
password: z.string('Password is required').min(8, 'Must be at least 8 characters')
})
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import { useRegle, type InferInput } from '@regle/core'
import { required, email, minLength, withMessage } from '@regle/rules'
const { r$ } = useRegle({ email: '', password: '' }, {
email: { required, email: withMessage(email, 'Invalid email') },
password: { required, minLength: withMessage(minLength(8), 'Must be at least 8 characters') }
})
type Schema = InferInput<typeof r$>
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="r$" :state="r$.$value" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="r$.$value.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="r$.$value.password" type="password" />
</B24FormField>
<B24Button type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { object, string } from 'yup'
import type { InferType } from 'yup'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = object({
email: string().email('Invalid email').required('Required'),
password: string()
.min(8, 'Must be at least 8 characters')
.required('Required')
})
type Schema = InferType<typeof schema>
const state = reactive({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { object, string } from 'yup'
import type { InferType } from 'yup'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = object({
email: string().email('Invalid email').required('Required'),
password: string()
.min(8, 'Must be at least 8 characters')
.required('Required')
})
type Schema = InferType<typeof schema>
const state = reactive({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import Joi from 'joi'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = Joi.object({
email: Joi.string().required(),
password: Joi.string()
.min(8)
.required()
})
const state = reactive({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<typeof state>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import Joi from 'joi'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = Joi.object({
email: Joi.string().required(),
password: Joi.string()
.min(8)
.required()
})
const state = reactive({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<typeof state>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { object, string, nonempty, refine } from 'superstruct'
import type { Infer } from 'superstruct'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = object({
email: nonempty(string()),
password: refine(string(), 'Password', (value) => {
if (value.length >= 8) return true
return 'Must be at least 8 characters'
})
})
const state = reactive({
email: '',
password: ''
})
type Schema = Infer<typeof schema>
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { object, string, nonempty, refine } from 'superstruct'
import type { Infer } from 'superstruct'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = object({
email: nonempty(string()),
password: refine(string(), 'Password', (value) => {
if (value.length >= 8) return true
return 'Must be at least 8 characters'
})
})
const state = reactive({
email: '',
password: ''
})
type Schema = Infer<typeof schema>
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
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- thenameof theFormFieldto send the error to.
schema prop to handle complex use cases.<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const state = reactive({
email: undefined,
password: undefined
})
type Schema = typeof state
function validate(state: Partial<Schema>): 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<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :validate="validate" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { FormError, FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const state = reactive({
email: undefined,
password: undefined
})
type Schema = typeof state
function validate(state: Partial<Schema>): 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<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :validate="validate" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Password" name="password">
<B24Input v-model="state.password" type="password" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
Error reporting
Errors are matched to the corresponding FormField using its name prop. An error on the email field is shown by <FormField name="email">.
Nested fields are matched using dot notation. A schema like { user: z.object({ email: z.string() }) } will be applied to <FormField name="user.email">.
tags.0, tags.1) and won't match <FormField name="tags"> by name alone. Use the error-pattern prop with a regular expression like /^tags\..+/ to capture them. This is especially useful for components like InputTags.<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = z.object({
email: z.email('Invalid email'),
tags: z.array(z.string().regex(/^[a-z-]+$/, 'Lowercase letters and dashes only')).min(1, 'Please add at least one tag')
})
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({
email: undefined,
tags: []
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Tags" name="tags" :error-pattern="/^tags\..+/">
<B24InputTags v-model="state.tags" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const schema = z.object({
email: z.email('Invalid email'),
tags: z.array(z.string().regex(/^[a-z-]+$/, 'Lowercase letters and dashes only')).min(1, 'Please add at least one tag')
})
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({
email: undefined,
tags: []
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" />
</B24FormField>
<B24FormField label="Tags" name="tags" :error-pattern="/^tags\..+/">
<B24InputTags v-model="state.tags" />
</B24FormField>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
Input events
The Form component automatically triggers validation when an input emits an input, change, or blur event.
- Validation on
inputoccurs as you type. - Validation on
changeoccurs when you commit to a value. - Validation on
blurhappens when an input loses focus.
You can control when validation happens this using the validate-on prop.
useFormField composable to implement this inside your own components.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'sid.name- thenameof theFormFieldmessage- the error message to display.
Here's an example that focuses the first input element with an error after the form is submitted:
<script setup lang="ts">
import type { FormError, FormErrorEvent, FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const state = reactive({
email: undefined,
password: undefined
})
type Schema = typeof state
function validate(state: Partial<Schema>): 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<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-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>
<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>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { FormError, FormErrorEvent, FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const state = reactive({
email: undefined,
password: undefined
})
type Schema = typeof state
function validate(state: Partial<Schema>): 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<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-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>
<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>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</B24Form>
</template>
HTML5 validation
When calling form.submit() programmatically, the Form component automatically triggers native HTML5 validation before submission.
<script setup lang="ts">
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const state = reactive({
email: undefined,
age: undefined
})
type Schema = typeof state
const form = useTemplateRef('form')
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<div class="space-y-4">
<B24Form ref="form" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" type="email" required class="w-full" />
</B24FormField>
<B24FormField label="Age" name="age">
<B24InputNumber v-model="state.age" :min="18" :max="100" required class="w-full" />
</B24FormField>
</B24Form>
<B24Button color="air-primary" @click="form?.submit()">
Submit
</B24Button>
</div>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
const state = reactive({
email: undefined,
age: undefined
})
type Schema = typeof state
const form = useTemplateRef('form')
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<div class="space-y-4">
<B24Form ref="form" :state="state" class="space-y-4" @submit="onSubmit">
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" type="email" required class="w-full" />
</B24FormField>
<B24FormField label="Age" name="age">
<B24InputNumber v-model="state.age" :min="18" :max="100" required class="w-full" />
</B24FormField>
</B24Form>
<B24Button color="air-primary" @click="form?.submit()">
Submit
</B24Button>
</div>
</template>
Nesting forms
Use the nested prop to nest multiple Form components and link their validation functions. In this case, validating the parent form will automatically validate all the other forms inside it.
Nested forms directly inherit their parent's state, so you don't need to define a separate state for them. You can use the name prop to target a nested attribute within the parent's state.
It can be used to dynamically add fields based on user's input:
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
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.email()
})
type NestedSchema = z.output<typeof nestedSchema>
const state = reactive<Partial<Schema & NestedSchema>>({ })
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form
ref="form"
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@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" :schema="nestedSchema" nested>
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" placeholder="john@lennon.com" />
</B24FormField>
</B24Form>
<div>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</div>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
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.email()
})
type NestedSchema = z.output<typeof nestedSchema>
const state = reactive<Partial<Schema & NestedSchema>>({ })
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form
ref="form"
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@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" :schema="nestedSchema" nested>
<B24FormField label="Email" name="email">
<B24Input v-model="state.email" placeholder="john@lennon.com" />
</B24FormField>
</B24Form>
<div>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</div>
</B24Form>
</template>
Or to validate list inputs:
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
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)
})
type ItemSchema = z.output<typeof itemSchema>
const state = reactive<Partial<Schema & { items: Partial<ItemSchema>[] }>>({ })
function addItem() {
if (!state.items) {
state.items = []
}
state.items.push({})
}
function removeItem() {
if (state.items) {
state.items.pop()
}
}
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@submit="onSubmit"
>
<B24FormField label="Customer" name="customer">
<B24Input v-model="state.customer" placeholder="Wonka Industries" />
</B24FormField>
<B24Form
v-for="(item, count) in state.items"
:key="count"
:name="`items.${count}`"
:schema="itemSchema"
class="flex gap-2"
nested
>
<B24FormField :label="!count ? 'Description' : undefined" name="description">
<B24Input v-model="item.description" />
</B24FormField>
<B24FormField :label="!count ? 'Price' : undefined" name="price" class="w-20">
<B24Input v-model="item.price" type="number" />
</B24FormField>
</B24Form>
<div class="flex gap-2">
<B24Button size="sm" @click="addItem()">
Add Item
</B24Button>
<B24Button size="sm" @click="removeItem()">
Remove Item
</B24Button>
</div>
<div>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</div>
</B24Form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
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)
})
type ItemSchema = z.output<typeof itemSchema>
const state = reactive<Partial<Schema & { items: Partial<ItemSchema>[] }>>({ })
function addItem() {
if (!state.items) {
state.items = []
}
state.items.push({})
}
function removeItem() {
if (state.items) {
state.items.pop()
}
}
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
</script>
<template>
<B24Form
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@submit="onSubmit"
>
<B24FormField label="Customer" name="customer">
<B24Input v-model="state.customer" placeholder="Wonka Industries" />
</B24FormField>
<B24Form
v-for="(item, count) in state.items"
:key="count"
:name="`items.${count}`"
:schema="itemSchema"
class="flex gap-2"
nested
>
<B24FormField :label="!count ? 'Description' : undefined" name="description">
<B24Input v-model="item.description" />
</B24FormField>
<B24FormField :label="!count ? 'Price' : undefined" name="price" class="w-20">
<B24Input v-model="item.price" type="number" />
</B24FormField>
</B24Form>
<div class="flex gap-2">
<B24Button size="sm" @click="addItem()">
Add Item
</B24Button>
<B24Button size="sm" @click="removeItem()">
Remove Item
</B24Button>
</div>
<div>
<B24Button color="air-primary" type="submit">
Submit
</B24Button>
</div>
</B24Form>
</template>
Examples
Record edit section
A common record-edit pattern (UF placement, slider context): a titled section with vertical-label fields, a two-column row for amount + currency, a nested "Client" sub-section, and a series of additional fields. Built entirely from B24Form, B24FormField, B24Input, B24Select, B24InputNumber, B24InputDate, B24Popover and B24Calendar — no custom components.
The full field set: Stage (B24Select), Amount and currency (B24InputNumber + B24Select in a two-column row), a "Client" group (Company + Contact B24Inputs and an "Add participant" link), then Salutation (B24Select), Last name and First name (B24Input), Service type (B24Select) and Scheduled date (B24InputDate with a dropdown B24Calendar in a B24Popover).
The "Client" sub-section is just a <div> with a label above and a bordered container (rounded-md border ... p-3 sm:p-4 space-y-4) wrapping nested B24FormFields — role="group" + aria-labelledby associate the label with the group. The two-column "Amount and currency" row uses grid-cols-1 sm:grid-cols-[1fr_auto] so the currency drops below the amount on narrow viewports. B24InputDate binds an @internationalized/date value (not a native Date), so the schema types that field loosely.
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import SettingsIcon from '@bitrix24/b24icons-vue/outline/SettingsIcon'
import CalendarIcon from '@bitrix24/b24icons-vue/outline/CalendarIcon'
import CirclePlusIcon from '@bitrix24/b24icons-vue/main/CirclePlusIcon'
import UserCompanyIcon from '@bitrix24/b24icons-vue/common-b24/UserCompanyIcon'
import PersonIcon from '@bitrix24/b24icons-vue/main/PersonIcon'
const stages = [
'Contact information review',
'Solution design',
'Awaiting parts',
'Scheduled',
'Completed'
]
const currencies = ['USD', 'EUR', 'RUB'] as const
const salutations = ['Mr.', 'Ms.', 'Dr.', 'Not selected']
const serviceTypes = [
'On-site installation',
'Remote setup',
'Consulting',
'Maintenance contract'
]
const schema = z.object({
stage: z.string().min(1, 'Stage is required'),
amount: z.number().nonnegative('Amount must be 0 or more'),
currency: z.enum(currencies),
company: z.string().optional(),
contact: z.string().optional(),
salutation: z.string().optional(),
lastName: z.string().min(1, 'Last name is required'),
firstName: z.string().min(1, 'First name is required'),
serviceType: z.string().min(1, 'Service type is required'),
// B24InputDate binds an @internationalized/date DateValue, not a native Date,
// so the field is typed loosely here instead of z.date().
scheduledAt: z.any().optional()
})
type Schema = z.output<typeof schema>
function defaultState(): Partial<Schema> {
return {
stage: 'Contact information review',
amount: 0,
currency: 'USD',
company: undefined,
contact: undefined,
salutation: 'Not selected',
lastName: 'Sidorov',
firstName: 'Ivan',
serviceType: undefined,
scheduledAt: undefined
}
}
const state = reactive<Partial<Schema>>(defaultState())
const clientGroupId = useId()
const scheduledAtInput = useTemplateRef('scheduledAtInput')
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Order saved', description: 'The order details have been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
function onCancel() {
Object.assign(state, defaultState())
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4 w-full max-w-lg" @submit="onSubmit">
<div class="flex items-center justify-between gap-2 pb-2 border-b border-(--ui-color-divider-vibrant-default)">
<h3 class="min-w-0 truncate text-(length:--ui-font-size-lg) font-(--ui-font-weight-semi-bold) uppercase tracking-wide">
Order details
</h3>
<B24Button color="air-tertiary-no-accent" size="sm" :icon="SettingsIcon" aria-label="Edit section" class="shrink-0" />
</div>
<B24FormField label="Stage" name="stage">
<!-- On Select, `class` targets the trigger while `b24ui.root` targets the outer wrapper, so both are needed for a full-width control. -->
<B24Select v-model="state.stage" :items="stages" class="w-full" :b24ui="{ root: 'w-full' }" />
</B24FormField>
<B24FormField label="Amount and currency" name="amount">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">
<B24InputNumber v-model="state.amount" :min="0" class="w-full" />
<!-- The row is validated under the `amount` field; currency is a fixed enum with a default, so it needs no separate error slot. -->
<B24Select v-model="state.currency" :items="[...currencies]" class="w-full sm:w-32" :b24ui="{ root: 'w-full sm:w-32' }" />
</div>
</B24FormField>
<!-- Inner "Client" section: a labelled, bordered group built from plain elements
(b24ui has no dedicated fieldset primitive). role/aria-labelledby tie the
heading to the group for screen readers. -->
<div class="space-y-1.5">
<span :id="clientGroupId" class="block text-(length:--ui-font-size-sm) text-(--ui-color-typography-secondary)">
Client
</span>
<div
role="group"
:aria-labelledby="clientGroupId"
class="rounded-md border border-(--ui-color-design-outline-stroke) p-3 sm:p-4 space-y-4"
>
<B24FormField label="Company" name="company">
<B24Input
v-model="state.company"
:icon="UserCompanyIcon"
placeholder="Company name, phone or email"
class="w-full"
/>
</B24FormField>
<B24FormField label="Contact" name="contact">
<B24Input
v-model="state.contact"
:icon="PersonIcon"
placeholder="Contact name, phone or email"
class="w-full"
/>
</B24FormField>
<B24Button color="air-tertiary-no-accent" size="sm" :icon="CirclePlusIcon" label="Add participant" />
</div>
</div>
<B24FormField label="Salutation" name="salutation">
<B24Select v-model="state.salutation" :items="salutations" class="w-full" :b24ui="{ root: 'w-full' }" />
</B24FormField>
<B24FormField label="Last name" name="lastName">
<B24Input v-model="state.lastName" class="w-full" />
</B24FormField>
<B24FormField label="First name" name="firstName">
<B24Input v-model="state.firstName" class="w-full" />
</B24FormField>
<B24FormField label="Service type" name="serviceType">
<B24Select v-model="state.serviceType" :items="serviceTypes" placeholder="Choose a service" class="w-full" :b24ui="{ root: 'w-full' }" />
</B24FormField>
<B24FormField label="Scheduled date" name="scheduledAt">
<!-- Free-text date input with a dropdown B24Calendar in the trailing slot; both share the same v-model. -->
<B24InputDate ref="scheduledAtInput" v-model="state.scheduledAt" class="w-full">
<template #trailing>
<B24Popover :reference="scheduledAtInput?.inputsRef[3]?.$el">
<B24Button
color="air-tertiary-no-accent"
size="sm"
:icon="CalendarIcon"
aria-label="Select a date"
class="px-0"
/>
<template #content>
<B24Calendar v-model="state.scheduledAt" class="p-2" />
</template>
</B24Popover>
</template>
</B24InputDate>
</B24FormField>
<div class="flex flex-wrap justify-end gap-2 pt-2">
<B24Button color="air-tertiary" label="Cancel" @click="onCancel" />
<B24Button color="air-primary" type="submit" label="Save" />
</div>
</B24Form>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef, useId } from 'vue'
import * as z from 'zod'
import type { FormSubmitEvent } from '@bitrix24/b24ui-nuxt'
import SettingsIcon from '@bitrix24/b24icons-vue/outline/SettingsIcon'
import CalendarIcon from '@bitrix24/b24icons-vue/outline/CalendarIcon'
import CirclePlusIcon from '@bitrix24/b24icons-vue/main/CirclePlusIcon'
import UserCompanyIcon from '@bitrix24/b24icons-vue/common-b24/UserCompanyIcon'
import PersonIcon from '@bitrix24/b24icons-vue/main/PersonIcon'
const stages = [
'Contact information review',
'Solution design',
'Awaiting parts',
'Scheduled',
'Completed'
]
const currencies = ['USD', 'EUR', 'RUB'] as const
const salutations = ['Mr.', 'Ms.', 'Dr.', 'Not selected']
const serviceTypes = [
'On-site installation',
'Remote setup',
'Consulting',
'Maintenance contract'
]
const schema = z.object({
stage: z.string().min(1, 'Stage is required'),
amount: z.number().nonnegative('Amount must be 0 or more'),
currency: z.enum(currencies),
company: z.string().optional(),
contact: z.string().optional(),
salutation: z.string().optional(),
lastName: z.string().min(1, 'Last name is required'),
firstName: z.string().min(1, 'First name is required'),
serviceType: z.string().min(1, 'Service type is required'),
// B24InputDate binds an @internationalized/date DateValue, not a native Date,
// so the field is typed loosely here instead of z.date().
scheduledAt: z.any().optional()
})
type Schema = z.output<typeof schema>
function defaultState(): Partial<Schema> {
return {
stage: 'Contact information review',
amount: 0,
currency: 'USD',
company: undefined,
contact: undefined,
salutation: 'Not selected',
lastName: 'Sidorov',
firstName: 'Ivan',
serviceType: undefined,
scheduledAt: undefined
}
}
const state = reactive<Partial<Schema>>(defaultState())
const clientGroupId = useId()
const scheduledAtInput = useTemplateRef('scheduledAtInput')
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Order saved', description: 'The order details have been submitted.', color: 'air-primary-success' })
console.log(event.data)
}
function onCancel() {
Object.assign(state, defaultState())
}
</script>
<template>
<B24Form :schema="schema" :state="state" class="space-y-4 w-full max-w-lg" @submit="onSubmit">
<div class="flex items-center justify-between gap-2 pb-2 border-b border-(--ui-color-divider-vibrant-default)">
<h3 class="min-w-0 truncate text-(length:--ui-font-size-lg) font-(--ui-font-weight-semi-bold) uppercase tracking-wide">
Order details
</h3>
<B24Button color="air-tertiary-no-accent" size="sm" :icon="SettingsIcon" aria-label="Edit section" class="shrink-0" />
</div>
<B24FormField label="Stage" name="stage">
<!-- On Select, `class` targets the trigger while `b24ui.root` targets the outer wrapper, so both are needed for a full-width control. -->
<B24Select v-model="state.stage" :items="stages" class="w-full" :b24ui="{ root: 'w-full' }" />
</B24FormField>
<B24FormField label="Amount and currency" name="amount">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">
<B24InputNumber v-model="state.amount" :min="0" class="w-full" />
<!-- The row is validated under the `amount` field; currency is a fixed enum with a default, so it needs no separate error slot. -->
<B24Select v-model="state.currency" :items="[...currencies]" class="w-full sm:w-32" :b24ui="{ root: 'w-full sm:w-32' }" />
</div>
</B24FormField>
<!-- Inner "Client" section: a labelled, bordered group built from plain elements
(b24ui has no dedicated fieldset primitive). role/aria-labelledby tie the
heading to the group for screen readers. -->
<div class="space-y-1.5">
<span :id="clientGroupId" class="block text-(length:--ui-font-size-sm) text-(--ui-color-typography-secondary)">
Client
</span>
<div
role="group"
:aria-labelledby="clientGroupId"
class="rounded-md border border-(--ui-color-design-outline-stroke) p-3 sm:p-4 space-y-4"
>
<B24FormField label="Company" name="company">
<B24Input
v-model="state.company"
:icon="UserCompanyIcon"
placeholder="Company name, phone or email"
class="w-full"
/>
</B24FormField>
<B24FormField label="Contact" name="contact">
<B24Input
v-model="state.contact"
:icon="PersonIcon"
placeholder="Contact name, phone or email"
class="w-full"
/>
</B24FormField>
<B24Button color="air-tertiary-no-accent" size="sm" :icon="CirclePlusIcon" label="Add participant" />
</div>
</div>
<B24FormField label="Salutation" name="salutation">
<B24Select v-model="state.salutation" :items="salutations" class="w-full" :b24ui="{ root: 'w-full' }" />
</B24FormField>
<B24FormField label="Last name" name="lastName">
<B24Input v-model="state.lastName" class="w-full" />
</B24FormField>
<B24FormField label="First name" name="firstName">
<B24Input v-model="state.firstName" class="w-full" />
</B24FormField>
<B24FormField label="Service type" name="serviceType">
<B24Select v-model="state.serviceType" :items="serviceTypes" placeholder="Choose a service" class="w-full" :b24ui="{ root: 'w-full' }" />
</B24FormField>
<B24FormField label="Scheduled date" name="scheduledAt">
<!-- Free-text date input with a dropdown B24Calendar in the trailing slot; both share the same v-model. -->
<B24InputDate ref="scheduledAtInput" v-model="state.scheduledAt" class="w-full">
<template #trailing>
<B24Popover :reference="scheduledAtInput?.inputsRef[3]?.$el">
<B24Button
color="air-tertiary-no-accent"
size="sm"
:icon="CalendarIcon"
aria-label="Select a date"
class="px-0"
/>
<template #content>
<B24Calendar v-model="state.scheduledAt" class="p-2" />
</template>
</B24Popover>
</template>
</B24InputDate>
</B24FormField>
<div class="flex flex-wrap justify-end gap-2 pt-2">
<B24Button color="air-tertiary" label="Cancel" @click="onCancel" />
<B24Button color="air-primary" type="submit" label="Save" />
</div>
</B24Form>
</template>
Build a record-edit form section (slider / UF placement edit panel).
Lean on the b24-ui-nuxt skill (see references/guidelines/forms.md → "Record-edit form pattern") to build a record-edit form section — the titled edit panel used in Bitrix24 sliders and UF placements. Assemble it only from stock primitives (B24Form, B24FormField, B24Input, B24Select, B24InputNumber, B24InputDate, B24Popover, B24Calendar) under a single :schema; no custom components.
Before writing any code, ask me for the missing context — don't assume:
- Which record is being edited (deal, lead, order, ticket, contact…)? That decides the section title, the field set and the validation rules.
- Which fields belong in the section, in what order, and which are required? Group related ones (e.g. a "Client" sub-section) and flag any two-column rows (such as amount + currency).
- For each field, which control fits: enum →
B24Select(list the options), free text →B24Input, number →B24InputNumber, date →B24InputDatewith a dropdownB24Calendar. - The header affordance: a title plus a single icon-only action on the right — confirm the icon and its
aria-label. - Locale, the default values used by the reset action, and what submit should do (toast, API call, close the slider).
Once those answers are in:
- Build the labelled sub-section as a
<div role="group" :aria-labelledby="...">whose id comes fromuseId()(b24ui has no fieldset primitive). - For a full-width
B24Select, set bothclass(the trigger) and:b24ui="{ root: 'w-full' }"(the wrapper);B24Input/B24InputNumberneed onlyclass="w-full". - Type the date field loosely in the schema (
z.any().optional()) —B24InputDatebinds an@internationalized/datevalue, not a nativeDate— and pair the input with aB24Calendarin its#trailingB24Popover, both sharing the samev-model. - Use
color="air-tertiary-no-accent"for icon/link-style buttons (the header action and "Add participant") — there is novariantprop onB24Button.
Keep all copy in the requested locale.
Task form layout
Real-world example: a Bitrix24-style task form assembled from standard components only — Input for the title, Editor (with a minimal toolbar row) for the description, Card, Avatar, and InputDate for the responsible-persons block, and a wrap row of Button actions. Single-column layout throughout — no custom CSS beyond component props.
<script setup lang="ts">
import type { EditorToolbarItem, IconComponent } from '@bitrix24/b24ui-nuxt'
import { CalendarDate } from '@internationalized/date'
import FileUploadIcon from '@bitrix24/b24icons-vue/main/FileUploadIcon'
import GoToLIcon from '@bitrix24/b24icons-vue/outline/GoToLIcon'
import PersonIcon from '@bitrix24/b24icons-vue/main/PersonIcon'
import MentionIcon from '@bitrix24/b24icons-vue/outline/MentionIcon'
import BulletedListIcon from '@bitrix24/b24icons-vue/outline/BulletedListIcon'
import NumberedListIcon from '@bitrix24/b24icons-vue/outline/NumberedListIcon'
import PlusLIcon from '@bitrix24/b24icons-vue/outline/PlusLIcon'
import CalendarIcon from '@bitrix24/b24icons-vue/outline/CalendarIcon'
import CircleCheckIcon from '@bitrix24/b24icons-vue/outline/CircleCheckIcon'
import TaskListIcon from '@bitrix24/b24icons-vue/outline/TaskListIcon'
import FolderPlusIcon from '@bitrix24/b24icons-vue/outline/FolderPlusIcon'
import UserGroupIcon from '@bitrix24/b24icons-vue/common-b24/UserGroupIcon'
import NotificationIcon from '@bitrix24/b24icons-vue/outline/NotificationIcon'
import BusinesProcessStagesIcon from '@bitrix24/b24icons-vue/outline/BusinesProcessStagesIcon'
import PinIcon from '@bitrix24/b24icons-vue/outline/PinIcon'
import BellIcon from '@bitrix24/b24icons-vue/main/BellIcon'
import ItemIcon from '@bitrix24/b24icons-vue/crm/ItemIcon'
import ArrowTopLIcon from '@bitrix24/b24icons-vue/outline/ArrowTopLIcon'
import ArrowDownLIcon from '@bitrix24/b24icons-vue/outline/ArrowDownLIcon'
import LinkIcon from '@bitrix24/b24icons-vue/outline/LinkIcon'
import LayersIcon from '@bitrix24/b24icons-vue/outline/LayersIcon'
import ClockIcon from '@bitrix24/b24icons-vue/outline/ClockIcon'
import SettingsIcon from '@bitrix24/b24icons-vue/outline/SettingsIcon'
const title = ref('Design the new task form interface')
const description = ref('')
const deadline = shallowRef<CalendarDate | undefined>(new CalendarDate(2026, 6, 30))
const deadlineInput = useTemplateRef('deadlineInput')
const toolbarItems: EditorToolbarItem[][] = [[
{ kind: 'mention', icon: MentionIcon, tooltip: { text: 'Mention' } },
{ kind: 'bulletList', icon: BulletedListIcon, tooltip: { text: 'Bullet list' } },
{ kind: 'orderedList', icon: NumberedListIcon, tooltip: { text: 'Numbered list' } }
]]
const actionButtons: { label: string, icon: IconComponent, active?: boolean }[] = [
{ label: 'Results', icon: CircleCheckIcon, active: true },
{ label: 'Files', icon: FileUploadIcon, active: true },
{ label: 'Checklists', icon: TaskListIcon },
{ label: 'Project', icon: FolderPlusIcon },
{ label: 'Co-executors', icon: UserGroupIcon },
{ label: 'Observers', icon: NotificationIcon },
{ label: 'Flow', icon: BusinesProcessStagesIcon },
{ label: 'Tags', icon: PinIcon, active: true },
{ label: 'Reminders', icon: BellIcon },
{ label: 'CRM elements', icon: ItemIcon },
{ label: 'Parent task', icon: ArrowTopLIcon },
{ label: 'Subtasks', icon: ArrowDownLIcon },
{ label: 'Linked tasks', icon: LinkIcon },
{ label: 'Gantt', icon: LayersIcon },
{ label: 'Timeline planning', icon: CalendarIcon },
{ label: 'Time tracking', icon: ClockIcon },
{ label: 'Custom fields', icon: SettingsIcon }
]
</script>
<template>
<div class="flex flex-col gap-4 p-4 w-full">
<B24Input
v-model="title"
placeholder="Task name"
size="xl"
no-border
:b24ui="{ base: 'font-(--ui-font-weight-semi-bold)' }"
/>
<B24Card :b24ui="{ body: 'p-0' }">
<B24Editor
v-slot="{ editor }"
v-model="description"
content-type="markdown"
placeholder="Add a task description..."
:b24ui="{ base: 'min-h-48 px-4 py-3' }"
>
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-(--ui-color-divider-default)">
<B24Button :icon="FileUploadIcon" color="air-tertiary" variant="ghost" size="sm" aria-label="Attach file" />
<B24EditorToolbar :editor="editor" :items="toolbarItems" />
<div class="ml-auto">
<B24Button :icon="GoToLIcon" color="air-tertiary" variant="ghost" size="sm" aria-label="Expand editor" />
</div>
</div>
</B24Editor>
</B24Card>
<B24Card :b24ui="{ body: 'p-0' }">
<div class="divide-y divide-(--ui-color-divider-default)">
<div class="flex items-center gap-3 px-5 py-3">
<span class="text-description text-sm w-28 shrink-0">Creator</span>
<div class="flex items-center gap-2 min-w-0">
<B24Avatar :icon="PersonIcon" color="air-secondary-accent-2" size="xs" />
<span class="text-sm truncate">Jane Cooper</span>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-3">
<span class="text-description text-sm w-28 shrink-0">Assignee</span>
<div class="flex items-center gap-2 min-w-0">
<B24Avatar :icon="PersonIcon" color="air-secondary-alert" size="xs" />
<span class="text-sm truncate">Unassigned</span>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-3">
<span class="text-description text-sm w-28 shrink-0">Deadline</span>
<B24InputDate ref="deadlineInput" v-model="deadline" size="sm" no-border class="flex-1">
<template #trailing>
<B24Popover :reference="deadlineInput?.inputsRef[3]?.$el">
<B24Button
color="air-tertiary-no-accent"
size="sm"
:icon="CalendarIcon"
aria-label="Pick a date"
class="px-0"
/>
<template #content>
<B24Calendar v-model="deadline" class="p-2" />
</template>
</B24Popover>
</template>
</B24InputDate>
</div>
</div>
</B24Card>
<B24Card>
<template #header>
<div class="flex items-center justify-between">
<span class="text-sm font-(--ui-font-weight-medium)">Watchers</span>
<B24Button :icon="PlusLIcon" color="air-tertiary" variant="ghost" size="xs" aria-label="Add watcher" />
</div>
</template>
<div class="flex flex-wrap gap-2">
<B24Avatar :icon="PersonIcon" color="air-secondary-accent-2" size="xs" />
<B24Avatar :icon="PersonIcon" color="air-secondary-alert" size="xs" />
<B24Avatar :icon="PersonIcon" color="air-secondary" size="xs" />
</div>
</B24Card>
<div class="flex flex-wrap gap-2">
<B24Button
v-for="btn in actionButtons"
:key="btn.label"
:icon="btn.icon"
:label="btn.label"
:color="btn.active ? 'air-secondary-accent-2' : 'air-secondary-no-accent'"
:aria-pressed="btn.active ? 'true' : 'false'"
size="sm"
/>
</div>
<div class="flex gap-2 justify-end">
<B24Button label="Save" color="air-primary" />
<B24Button label="Cancel" color="air-tertiary" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, useTemplateRef } from 'vue'
import type { EditorToolbarItem, IconComponent } from '@bitrix24/b24ui-nuxt'
import { CalendarDate } from '@internationalized/date'
import FileUploadIcon from '@bitrix24/b24icons-vue/main/FileUploadIcon'
import GoToLIcon from '@bitrix24/b24icons-vue/outline/GoToLIcon'
import PersonIcon from '@bitrix24/b24icons-vue/main/PersonIcon'
import MentionIcon from '@bitrix24/b24icons-vue/outline/MentionIcon'
import BulletedListIcon from '@bitrix24/b24icons-vue/outline/BulletedListIcon'
import NumberedListIcon from '@bitrix24/b24icons-vue/outline/NumberedListIcon'
import PlusLIcon from '@bitrix24/b24icons-vue/outline/PlusLIcon'
import CalendarIcon from '@bitrix24/b24icons-vue/outline/CalendarIcon'
import CircleCheckIcon from '@bitrix24/b24icons-vue/outline/CircleCheckIcon'
import TaskListIcon from '@bitrix24/b24icons-vue/outline/TaskListIcon'
import FolderPlusIcon from '@bitrix24/b24icons-vue/outline/FolderPlusIcon'
import UserGroupIcon from '@bitrix24/b24icons-vue/common-b24/UserGroupIcon'
import NotificationIcon from '@bitrix24/b24icons-vue/outline/NotificationIcon'
import BusinesProcessStagesIcon from '@bitrix24/b24icons-vue/outline/BusinesProcessStagesIcon'
import PinIcon from '@bitrix24/b24icons-vue/outline/PinIcon'
import BellIcon from '@bitrix24/b24icons-vue/main/BellIcon'
import ItemIcon from '@bitrix24/b24icons-vue/crm/ItemIcon'
import ArrowTopLIcon from '@bitrix24/b24icons-vue/outline/ArrowTopLIcon'
import ArrowDownLIcon from '@bitrix24/b24icons-vue/outline/ArrowDownLIcon'
import LinkIcon from '@bitrix24/b24icons-vue/outline/LinkIcon'
import LayersIcon from '@bitrix24/b24icons-vue/outline/LayersIcon'
import ClockIcon from '@bitrix24/b24icons-vue/outline/ClockIcon'
import SettingsIcon from '@bitrix24/b24icons-vue/outline/SettingsIcon'
const title = ref('Design the new task form interface')
const description = ref('')
const deadline = shallowRef<CalendarDate | undefined>(new CalendarDate(2026, 6, 30))
const deadlineInput = useTemplateRef('deadlineInput')
const toolbarItems: EditorToolbarItem[][] = [[
{ kind: 'mention', icon: MentionIcon, tooltip: { text: 'Mention' } },
{ kind: 'bulletList', icon: BulletedListIcon, tooltip: { text: 'Bullet list' } },
{ kind: 'orderedList', icon: NumberedListIcon, tooltip: { text: 'Numbered list' } }
]]
const actionButtons: { label: string, icon: IconComponent, active?: boolean }[] = [
{ label: 'Results', icon: CircleCheckIcon, active: true },
{ label: 'Files', icon: FileUploadIcon, active: true },
{ label: 'Checklists', icon: TaskListIcon },
{ label: 'Project', icon: FolderPlusIcon },
{ label: 'Co-executors', icon: UserGroupIcon },
{ label: 'Observers', icon: NotificationIcon },
{ label: 'Flow', icon: BusinesProcessStagesIcon },
{ label: 'Tags', icon: PinIcon, active: true },
{ label: 'Reminders', icon: BellIcon },
{ label: 'CRM elements', icon: ItemIcon },
{ label: 'Parent task', icon: ArrowTopLIcon },
{ label: 'Subtasks', icon: ArrowDownLIcon },
{ label: 'Linked tasks', icon: LinkIcon },
{ label: 'Gantt', icon: LayersIcon },
{ label: 'Timeline planning', icon: CalendarIcon },
{ label: 'Time tracking', icon: ClockIcon },
{ label: 'Custom fields', icon: SettingsIcon }
]
</script>
<template>
<div class="flex flex-col gap-4 p-4 w-full">
<B24Input
v-model="title"
placeholder="Task name"
size="xl"
no-border
:b24ui="{ base: 'font-(--ui-font-weight-semi-bold)' }"
/>
<B24Card :b24ui="{ body: 'p-0' }">
<B24Editor
v-slot="{ editor }"
v-model="description"
content-type="markdown"
placeholder="Add a task description..."
:b24ui="{ base: 'min-h-48 px-4 py-3' }"
>
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-(--ui-color-divider-default)">
<B24Button :icon="FileUploadIcon" color="air-tertiary" variant="ghost" size="sm" aria-label="Attach file" />
<B24EditorToolbar :editor="editor" :items="toolbarItems" />
<div class="ml-auto">
<B24Button :icon="GoToLIcon" color="air-tertiary" variant="ghost" size="sm" aria-label="Expand editor" />
</div>
</div>
</B24Editor>
</B24Card>
<B24Card :b24ui="{ body: 'p-0' }">
<div class="divide-y divide-(--ui-color-divider-default)">
<div class="flex items-center gap-3 px-5 py-3">
<span class="text-description text-sm w-28 shrink-0">Creator</span>
<div class="flex items-center gap-2 min-w-0">
<B24Avatar :icon="PersonIcon" color="air-secondary-accent-2" size="xs" />
<span class="text-sm truncate">Jane Cooper</span>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-3">
<span class="text-description text-sm w-28 shrink-0">Assignee</span>
<div class="flex items-center gap-2 min-w-0">
<B24Avatar :icon="PersonIcon" color="air-secondary-alert" size="xs" />
<span class="text-sm truncate">Unassigned</span>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-3">
<span class="text-description text-sm w-28 shrink-0">Deadline</span>
<B24InputDate ref="deadlineInput" v-model="deadline" size="sm" no-border class="flex-1">
<template #trailing>
<B24Popover :reference="deadlineInput?.inputsRef[3]?.$el">
<B24Button
color="air-tertiary-no-accent"
size="sm"
:icon="CalendarIcon"
aria-label="Pick a date"
class="px-0"
/>
<template #content>
<B24Calendar v-model="deadline" class="p-2" />
</template>
</B24Popover>
</template>
</B24InputDate>
</div>
</div>
</B24Card>
<B24Card>
<template #header>
<div class="flex items-center justify-between">
<span class="text-sm font-(--ui-font-weight-medium)">Watchers</span>
<B24Button :icon="PlusLIcon" color="air-tertiary" variant="ghost" size="xs" aria-label="Add watcher" />
</div>
</template>
<div class="flex flex-wrap gap-2">
<B24Avatar :icon="PersonIcon" color="air-secondary-accent-2" size="xs" />
<B24Avatar :icon="PersonIcon" color="air-secondary-alert" size="xs" />
<B24Avatar :icon="PersonIcon" color="air-secondary" size="xs" />
</div>
</B24Card>
<div class="flex flex-wrap gap-2">
<B24Button
v-for="btn in actionButtons"
:key="btn.label"
:icon="btn.icon"
:label="btn.label"
:color="btn.active ? 'air-secondary-accent-2' : 'air-secondary-no-accent'"
:aria-pressed="btn.active ? 'true' : 'false'"
size="sm"
/>
</div>
<div class="flex gap-2 justify-end">
<B24Button label="Save" color="air-primary" />
<B24Button label="Cancel" color="air-tertiary" />
</div>
</div>
</template>
Build a Bitrix24-style task form using standard b24ui components.
Build a Bitrix24-style task form layout using only standard b24ui components (single column, no custom CSS beyond component props).
- Title:
B24Inputsize="xl"no-border, bold via:b24ui="{ base: 'font-(--ui-font-weight-semi-bold)' }" - Editor card:
B24Cardwithb24ui.body='p-0'containingB24Editor(content-type="markdown",min-h-48 px-4 py-3). Inside the editor's default slot, render a toolbar row (flex items-center gap-1 px-2 py-1.5 border-b): attachmentB24Buttonon the left,B24EditorToolbarwithmention/bulletList/orderedListin the middle, expandB24Button(GoToLIcon) pushed toml-auto - Responsible persons card:
B24Cardb24ui.body='p-0', body is adivide-ydiv with threepx-5 py-3rows — Creator (B24Avatar+ name), Assignee (B24Avatar+ name), Deadline (B24InputDatesize="sm"no-borderwithB24Popover+B24Calendarin the#trailingslot, both sharing the sameshallowRef<CalendarDate | undefined>) - Watchers card:
#headerwith label and+B24Button; body showsB24Avataricons - Action buttons:
flex flex-wrap gap-2row of 17B24Buttonsize="sm"— each item has anactiveflag; active →color="air-secondary-accent-2", inactive →color="air-secondary-no-accent". Buttons: Results, Files, Checklists, Project, Co-executors, Observers, Flow, Tags, Reminders, CRM elements, Parent task, Subtasks, Linked tasks, Gantt, Timeline planning, Time tracking, Custom fields - Footer:
flex gap-2 justify-endwith Save (air-primary) and Cancel (air-tertiary) - State:
titleanddescriptionasref;deadlineasshallowRef<CalendarDate | undefined>(from@internationalized/date—B24InputDatedoes not accept nativeDate);toolbarItemsas a plainconst(no reactive deps); action list typed as{ label: string, icon: IconComponent, active?: boolean }[]
API
Props
| Prop | Default | Type |
|---|---|---|
id | string | number | |
schema | SSchema to validate the form state. Supports Standard Schema objects, Yup, Joi, and Superstructs. | |
state | N extends false ? Partial<InferInput<S>> : neverAn object representing the current state of the form. | |
validate | (state: Partial<InferInput<S>>): FormError<string>[] | Promise<FormError<string>[]>Custom validation function to validate the form state. | |
validateOn | `['blur', 'change', 'input']` | FormInputEvents[]The list of input events that trigger the form validation. |
disabled | boolean Disable all inputs inside the form. | |
name | N extends true ? string : neverPath of the form's state within it's parent form.
Used for nesting forms. Only available if | |
validateOnInputDelay | 300 | numberDelay in milliseconds before validating the form on input events. |
transform | true as T | TIf true, applies schema transformations on submit. |
nested | `false` | NIf true, this form will attach to its parent Form and validate at the same time. |
loadingAuto | true | boolean When |
acceptcharset | string | |
action | string | |
autocomplete | string | |
enctype | string | |
method | string | |
novalidate | false | true | "true" | "false" | |
target | string | |
b24ui | { base?: any; } |
Slots
| Slot | Type |
|---|---|
default | { errors: FormErrorWithId[]; loading: boolean; } |
Emits
| Event | Type |
|---|---|
submit | [event: FormSubmitEvent<FormData<S, T>>] |
error | [event: FormErrorEvent] |
Expose
You can access the typed component instance using useTemplateRef.
<script setup lang="ts">
const form = useTemplateRef('form')
</script>
<template>
<B24Form ref="form" />
</template>
This will give you access to the following:
| Name | Type |
|---|---|
submit() | Promise<void> Triggers form submission with HTML5 validation. |
validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean }) | Promise<T> Triggers form validation. Will raise any errors unless |
clear(path?: keyof T | RegExp) | void Clears form errors associated with a specific path. If no path is provided, clears all form errors. |
getErrors(path?: keyof T | RegExp) | FormErrorWithId[] Retrieves form errors associated with a specific path. If no path is provided, returns all form errors. |
setErrors(errors: FormError[], name?: keyof T | RegExp) | void Sets form errors for a given path. If no path is provided, overrides all errors. |
errors | Ref<FormErrorWithId[]> A reference to the array containing validation errors. Use this to access or manipulate the error information. |
disabled | Ref<boolean> |
dirty | Ref<boolean> true if at least one form field has been updated by the user. |
dirtyFields | ReadonlySet<DeepReadonly<keyof T>> Tracks fields that have been modified by the user. |
touchedFields | ReadonlySet<DeepReadonly<keyof T>> Tracks fields that the user interacted with. |
blurredFields | ReadonlySet<DeepReadonly<keyof T>> Tracks fields blurred by the user. |
Theme
export default {
base: ''
}