Skip to content

Form ​

A neo-brutalist style form system built on vee-validate, providing composable sub-components for structured form layout.

Demo ​

Preview

Installation ​

pnpm dlx brutx-vue@latest add form

Additional dependencies required:

bash
pnpm add vee-validate @vee-validate/zod zod

Usage ​

vue
<script setup>
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from 'brutx-ui-vue/form'
import { Input, Button } from 'brutx-ui-vue'

const schema = toTypedSchema(z.object({
    username: z.string().min(2).max(50),
    email: z.string().email(),
}))

function onSubmit(values) {
    console.log('Form submitted:', values)
}
</script>

<template>
    <Form :validation-schema="schema" @submit="onSubmit">
        <FormField name="username" v-slot="{ componentField }">
            <FormItem>
                <FormLabel>Username</FormLabel>
                <FormControl>
                    <Input v-bind="componentField" placeholder="Enter username" />
                </FormControl>
                <FormDescription>This is your public display name.</FormDescription>
                <FormMessage />
            </FormItem>
        </FormField>

        <FormField name="email" v-slot="{ componentField }">
            <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                    <Input v-bind="componentField" type="email" placeholder="you@example.com" />
                </FormControl>
                <FormMessage />
            </FormItem>
        </FormField>

        <Button type="submit" variant="primary">Submit</Button>
    </Form>
</template>

FormWizard Multi-step Form ​

A wizard-style form built on the Stepper component, supporting step validation, linear navigation, and custom step content.

vue
<script setup>
import { ref } from 'vue'
import { FormWizard } from 'brutx-ui-vue/form'
import type { FormStep } from 'brutx-ui-vue/form'
import { Input } from 'brutx-ui-vue'

const values = ref({})

const steps = [
    { id: 'personal', title: 'Personal Info' },
    { id: 'address', title: 'Address' },
    { id: 'review', title: 'Review' },
]

function onComplete(finalValues) {
    console.log('Submitted:', finalValues)
}
</script>

<template>
    <FormWizard
        v-model="values"
        :steps="steps"
        @complete="onComplete"
    >
        <template #step-personal>
            <Input v-model="values.name" placeholder="Name" />
        </template>
        <template #step-address>
            <Input v-model="values.address" placeholder="Address" />
        </template>
        <template #step-review>
            <p>Review info: {{ values }}</p>
        </template>
    </FormWizard>
</template>

FormConditional Conditional Fields ​

Use a predicate to control whether a group is mounted. This example closes over caller-managed reactive values and passes initial values to Form:

vue
<script setup>
import { ref } from 'vue'
import { Form, FormConditional, FormField } from 'brutx-ui-vue/form'

const values = ref({ type: 'company' })
const showCompany = () => values.value.type === 'company'
const showPersonal = () => values.value.type === 'personal'
</script>

<template>
    <Form :initial-values="values">
        <FormField name="type" />

        <FormConditional :when="showCompany">
            <FormField name="companyName" />
            <FormField name="taxId" />
        </FormConditional>

        <FormConditional :when="showPersonal">
            <FormField name="idNumber" />
        </FormConditional>
    </Form>
</template>

Binding FormControl to an Input ​

FormControl requires exactly one root control. Its scoped slot provides the generated ID, description and invalid-state attributes, and the shared form size:

vue
<FormControl v-slot="{ id, ariaDescribedby, ariaInvalid, size }">
    <Input :id="id" :size="size" :aria-describedby="ariaDescribedby" :aria-invalid="ariaInvalid" />
</FormControl>

Sub-components ​

ComponentDescription
FormRoot form component, integrates with vee-validate
FormFieldField wrapper that connects form state and provides field context
FormItemLayout container for label, control, and message; generates unique IDs and applies form layout settings
FormLabelLabel with error state support, injects field context
FormControlInput control wrapper that places the control in the form grid and provides accessibility attributes and size via slot
FormDescriptionHelper text below the input
FormMessageValidation error message, injects field context
FormWizardMulti-step wizard-style form
FormConditionalDynamically shows/hides field groups based on form values

Data Types ​

FormFieldContext ​

FormField provides FormFieldContext to child components via provide/inject:

ts
interface FormFieldContext {
    name: ComputedRef<string>
    error: Ref<string | undefined>
    value: Ref<unknown>
    setValue: (value: unknown) => void
    setError: (message: string | undefined) => void
}

FormItemContext ​

FormItem provides FormItemContext to child components via provide/inject:

ts
interface FormItemContext {
    id: string
    formItemId: string
    formDescriptionId: string
    formMessageId: string
}

FormControl, FormLabel, and FormMessage all inject FormFieldContext and FormItemContext, providing direct access to field values and error states.

FormStep ​

ts
interface FormStep {
    id: string
    title: string
    description?: string
    icon?: Component
    validator?: (values: Record<string, unknown>) => ValidationResult
    optional?: boolean
}

ValidationResult ​

ts
interface ValidationResult {
    valid: boolean
    errors: Record<string, string>
}

Composables ​

useFormWizard ​

Access the wizard context within FormWizard child components:

ts
const {
    currentStep,    // Ref<number> - Current step
    steps,          // ComputedRef<FormStep[]> - Step configuration
    values,         // ComputedRef<Record<string, unknown>> - Form data
    updateValues,   // (values: Record<string, unknown>) => void - Update form data
    nextStep,       // () => void - Go to next step
    previousStep,   // () => void - Go to previous step
    goToStep,       // (step: number) => void - Jump to a step
    complete,       // () => void - Complete the form
    getStepErrors,  // (step: number) => Record<string, string> | undefined - Get errors for a specific step
    isFirstStep,    // ComputedRef<boolean>
    isLastStep,     // ComputedRef<boolean>
    canGoNext,      // ComputedRef<boolean>
} = useFormWizard()

Programmatic Control ​

Access the Form component instance via ref to call the following methods:

vue
<script setup>
import { ref } from 'vue'
import { Form } from 'brutx-ui-vue/form'

const formRef = ref(null)

async function handleSubmit() {
    const isValid = await formRef.value?.validate()
    if (isValid) {
        // Submit form
    }
}

function handleReset() {
    formRef.value?.resetFields()
}
</script>

<template>
    <Form ref="formRef" :scroll-to-error="true">
        <!-- Form fields -->
    </Form>
</template>

API Reference ​

Form

Form — Props
NameTypeDefaultDescription
inline
boolean | undefined
false
Arranges form items in a wrapping horizontal layout.
Details

Declared type

boolean | undefined

Literal values

  • false
  • true

Default value details

Resolved value
false

Source

Form.vue:9

packages/ui/src/components/form/Form.vue

labelPosition
"right" | "left" | "top" | undefined
"right"
left and right place labels to the left of controls with the corresponding alignment; top places labels above controls.
Details

Declared type

"right" | "left" | "top" | undefined

Literal values

  • "left"
  • "right"
  • "top"

Default value details

Resolved value
"right"

Source

Form.vue:11

packages/ui/src/components/form/Form.vue

labelWidth
string | number | undefined
undefined
The label-column width in horizontal label layouts. Numbers are pixels and strings are CSS widths; top layouts do not use this value.
Details

Declared type

string | number | undefined

Default value details

Declaration
undefined

Source: packages/ui/src/components/form/Form.vue

Resolved value
undefined

Source

Form.vue:13

packages/ui/src/components/form/Form.vue

scrollToError
boolean | undefined
false
Smoothly scrolls to the first invalid field when the instance validate() method fails. Native submission handling does not use this option.
Details

Declared type

boolean | undefined

Literal values

  • false
  • true

Default value details

Resolved value
false

Source

Form.vue:15

packages/ui/src/components/form/Form.vue

size
"default" | "lg" | "sm" | undefined
"default"
Provides a shared size for item spacing, labels, descriptions, and errors, and exposes size to controls through the FormControl slot.
Details

Declared type

"default" | "lg" | "sm" | undefined

Literal values

  • "default"
  • "lg"
  • "sm"

Default value details

Declaration
'default'

Source: packages/ui/src/components/form/Form.vue

Resolved value
"default"

Source

Form.vue:17

packages/ui/src/components/form/Form.vue

class
string | undefined
undefined
Custom CSS classes merged onto the native form root.
Details

Declared type

string | undefined

Default value details

Declaration
undefined

Source: packages/ui/src/components/form/Form.vue

Resolved value
undefined

Source

Form.vue:18

packages/ui/src/components/form/Form.vue

initialValues
Record<string, unknown> | undefined
undefined
Initial field values passed to vee-validate. A changed prop reference resets the form with the new values while the form is pristine.
Details

Declared type

Record<string, unknown> | undefined

Default value details

Declaration
undefined

Source: packages/ui/src/components/form/Form.vue

Resolved value
undefined

Source

Form.vue:19

packages/ui/src/components/form/Form.vue

validationSchema
unknown
undefined
The reactive validation schema passed to vee-validate, using supported rules or a converted Zod schema.
Details

Declared type

unknown

Default value details

Declaration
undefined

Source: packages/ui/src/components/form/Form.vue

Resolved value
undefined

Source

Form.vue:20

packages/ui/src/components/form/Form.vue

Form — Events
NameSignature or typeDescription
@submit
[values: Record<string, unknown>]
Emits field values after form submission passes vee-validate validation.
Details

Declared type

[values: Record<string, unknown>]

Source

Form.vue

packages/ui/src/components/form/Form.vue

Form — Slots
NameSignature or typeDescription
#default
{}
Form fields, descriptions, and submission controls. Descendants can access the validation and layout context provided by Form.
Details

Declared type

{}

Source

Form.vue

packages/ui/src/components/form/Form.vue

Form — Exposes
NameSignature or typeDescription
validate
() => Promise<boolean>
Validates all fields and asynchronously returns their validity. On failure, scrolls to the first invalid field when scrollToError is enabled.
Details

Declared type

() => Promise<boolean>

Source

Form.vue:140

packages/ui/src/components/form/Form.vue

validateField
(field: string) => Promise<boolean>
Validates one field by name and asynchronously returns whether it is valid.
Details

Declared type

(field: string) => Promise<boolean>

Source

Form.vue:141

packages/ui/src/components/form/Form.vue

resetFields
() => void
Calls vee-validate resetForm to restore fields and form state to their initial state.
Details

Declared type

() => void

Source

Form.vue:142

packages/ui/src/components/form/Form.vue

clearValidate
(fields?: string[] | undefined) => void
Clears validation errors for the specified fields. Omission or an empty array clears all errors while preserving current field values.
Details

Declared type

(fields?: string[] | undefined) => void

Source

Form.vue:143

packages/ui/src/components/form/Form.vue

scrollToField
(field: string) => void
Finds a form control by name and smoothly scrolls to its top. Radio groups use the first control; no scrolling occurs when the field is absent.
Details

Declared type

(field: string) => void

Source

Form.vue:144

packages/ui/src/components/form/Form.vue

FormConditional

FormConditional — Props
NameTypeDefaultDescription
when
Required
(values: Record<string, unknown>) => boolean
—
The predicate deciding whether conditional content is mounted. Use it inside Form and prefer a stable named function reference.
  • The current implementation reads values.value from the form context and passes an empty object when it is absent; a normal flat field record is not passed as the complete values object.
Details

Declared type

(values: Record<string, unknown>) => boolean

Default value details

Not declared
—

Source

FormConditional.vue:8

packages/ui/src/components/form/FormConditional.vue

class
string | undefined
undefined
CSS classes merged onto the content wrapper div when the condition is true.
Details

Declared type

string | undefined

Default value details

Resolved value
undefined

Source

FormConditional.vue:9

packages/ui/src/components/form/FormConditional.vue

FormConditional — Slots
NameSignature or typeDescription
#default
{}
Fields or other content mounted when the condition is true. The entire region is unmounted when it becomes false.
Details

Declared type

{}

Source

FormConditional.vue

packages/ui/src/components/form/FormConditional.vue

FormControl

FormControl — Props
NameTypeDefaultDescription
class
string | undefined
—
CSS classes merged with form-control layout styles and applied through as-child to the single root control in the default slot.
Details

Declared type

string | undefined

Default value details

Not declared
—

Source

FormControl.vue:9

packages/ui/src/components/form/FormControl.vue

FormControl — Slots
NameSignature or typeDescription
#default
{ ariaDescribedby: string | undefined; ariaInvalid: true | undefined; size: FormLayoutSize | undefined; class: string; id: string | undefined; }
Contains one root control. Provides the FormItem-linked id, class, ariaDescribedby, ariaInvalid, and Form size for explicit binding. The description relationship includes the error message when meaningful error text is present.
Details

Declared type

{ ariaDescribedby: string | undefined; ariaInvalid: true | undefined; size: FormLayoutSize | undefined; class: string; id: string | undefined; }

Referenced types

FormLayoutSize
Type definition — FormLayoutSize
export type FormLayoutSize = 'sm' | 'default' | 'lg'

Source

FormControl.vue

packages/ui/src/components/form/FormControl.vue

FormDescription

FormDescription — Props
NameTypeDefaultDescription
class
string | undefined
—
CSS classes merged onto the field description paragraph, which uses the description ID provided by FormItem.
Details

Declared type

string | undefined

Default value details

Not declared
—

Source

FormDescription.vue:12

packages/ui/src/components/form/FormDescription.vue

FormDescription — Slots
NameSignature or typeDescription
#default
{}
Supporting guidance for the field. The description paragraph is not rendered when this slot is omitted.
Details

Declared type

{}

Source

FormDescription.vue

packages/ui/src/components/form/FormDescription.vue

FormField

FormField — Props
NameTypeDefaultDescription
name
Required
string
—
The field name registered with vee-validate. Keep it stable during the component lifetime; changing it requires handling retained values and errors for the previous name.
Details

Declared type

string

Default value details

Not declared
—

Source

FormField.vue:8

packages/ui/src/components/form/FormField.vue

FormField — Slots
NameSignature or typeDescription
#default
{ componentField: { name: string; modelValue: unknown; 'onUpdate:modelValue': (e: unknown, shouldValidate?: boolean | undefined) => void; onFocusout: (event: FocusEvent) => void; }; }
Field content, typically FormItem. Provides componentField for v-bind on the control, including the field name, current value, value updates, and focusout validation.
Details

Declared type

{ componentField: { name: string; modelValue: unknown; 'onUpdate:modelValue': (e: unknown, shouldValidate?: boolean | undefined) => void; onFocusout: (event: FocusEvent) => void; }; }

Source

FormField.vue

packages/ui/src/components/form/FormField.vue

FormItem

FormItem — Props
NameTypeDefaultDescription
class
string | undefined
—
CSS classes merged onto the field layout container, which follows the Form label position, width, and size.
Details

Declared type

string | undefined

Default value details

Not declared
—

Source

FormItem.vue:12

packages/ui/src/components/form/FormItem.vue

FormItem — Slots
NameSignature or typeDescription
#default
{}
The field label, control, description, and error message, sharing the IDs generated by this FormItem.
Details

Declared type

{}

Source

FormItem.vue

packages/ui/src/components/form/FormItem.vue

FormLabel

FormLabel — Props
NameTypeDefaultDescription
class
string | undefined
—
CSS classes merged onto the field label. Meaningful error text automatically applies the error color.
Details

Declared type

string | undefined

Default value details

Not declared
—

Source

FormLabel.vue:9

packages/ui/src/components/form/FormLabel.vue

FormLabel — Slots
NameSignature or typeDescription
#default
{}
The field label content, linked through for to the FormItem control ID.
Details

Declared type

{}

Source

FormLabel.vue

packages/ui/src/components/form/FormLabel.vue

FormMessage

FormMessage — Props
NameTypeDefaultDescription
class
string | undefined
—
CSS classes merged onto the validation error paragraph. It renders with role="alert" only when the field error is non-empty after trimming.
Details

Declared type

string | undefined

Default value details

Not declared
—

Source

FormMessage.vue:12

packages/ui/src/components/form/FormMessage.vue

FormWizard

FormWizard — Props
NameTypeDefaultDescription
steps
Required
FormStep[]
—
The step configuration list with stable IDs, titles, and optional validators. Each ID determines its step-{id} slot name.
Details

Declared type

FormStep[]

Referenced types

FormStep
Type definition — FormStep
export interface FormStep {
    id: string
    title: string
    description?: string
    icon?: Component
    /** 入参为全量 values(跨步骤共享的扁平字段集合),约定只返回当前步骤相关字段的错误 */
    validator?: (values: Record<string, unknown>) => ValidationResult
    /** 线性导航下可跳过(不要求完成即可前进),但不豁免 validator */
    optional?: boolean
}

Default value details

Not declared
—

Source

form-wizard-types.ts:20

packages/ui/src/components/form/form-wizard-types.ts

modelValue
Record<string, unknown> | undefined
{ }

Factory evaluated per instance

The flat field record shared across steps and controlled through v-model. useFormWizard().updateValues() emits updates after shallowly merging field patches.
Details

Declared type

Record<string, unknown> | undefined

Default value details

Resolved value
{ }

Source

form-wizard-types.ts:21

packages/ui/src/components/form/form-wizard-types.ts

initialStep
number | undefined
0
The initial zero-based step index, clamped to the valid steps range at creation.
Details

Declared type

number | undefined

Default value details

Resolved value
0

Source

form-wizard-types.ts:22

packages/ui/src/components/form/form-wizard-types.ts

validateOnNext
boolean | undefined
true
Validates the current step when advancing, and revalidates all steps with validators when completing, including optional steps.
Details

Declared type

boolean | undefined

Literal values

  • false
  • true

Default value details

Resolved value
true

Source

form-wizard-types.ts:23

packages/ui/src/components/form/form-wizard-types.ts

showIndicator
boolean | undefined
true
Shows the top step indicator, which users can use to request navigation.
Details

Declared type

boolean | undefined

Literal values

  • false
  • true

Default value details

Resolved value
true

Source

form-wizard-types.ts:24

packages/ui/src/components/form/form-wizard-types.ts

linear
boolean | undefined
true
Requires preceding required steps to be completed before forward navigation. Optional steps may be skipped; backward navigation is unrestricted.
Details

Declared type

boolean | undefined

Literal values

  • false
  • true

Default value details

Resolved value
true

Source

form-wizard-types.ts:25

packages/ui/src/components/form/form-wizard-types.ts

class
string | undefined
undefined
CSS classes merged onto the wizard root container with role="form".
Details

Declared type

string | undefined

Default value details

Resolved value
undefined

Source

form-wizard-types.ts:26

packages/ui/src/components/form/form-wizard-types.ts

FormWizard — Events
NameSignature or typeDescription
@update:modelValue
[values: Record<string, unknown>]
Emits the complete field record after shallowly merging a field update made through the wizard context.
Details

Declared type

[values: Record<string, unknown>]

Source

FormWizard.vue

packages/ui/src/components/form/FormWizard.vue

@complete
[values: Record<string, unknown>]
Emits all form data when the completion action passes the enabled step validation.
Details

Declared type

[values: Record<string, unknown>]

Source

FormWizard.vue

packages/ui/src/components/form/FormWizard.vue

@step-change
[step: number, previousStep: number]
Emits the target and previous zero-based indices on navigation or when completion validation returns to a failing step.
Details

Declared type

[step: number, previousStep: number]

Source

FormWizard.vue

packages/ui/src/components/form/FormWizard.vue

@validation-error
[step: number, errors: Record<string, string>]
Emits the step index and field error record when a step validator returns an invalid result.
Details

Declared type

[step: number, errors: Record<string, string>]

Source

FormWizard.vue

packages/ui/src/components/form/FormWizard.vue

@navigation-blocked
[targetStep: number, blockedStep: number]
Emits the target and blocking indices when linear navigation encounters an incomplete required preceding step.
Details

Declared type

[targetStep: number, blockedStep: number]

Source

FormWizard.vue

packages/ui/src/components/form/FormWizard.vue

FormWizard — Slots
NameSignature or typeDescription
#step-{id}
{ step: FormStep; index: number }
A dynamic slot named after the step ID, receiving its configuration and zero-based index. All step content remains mounted, with v-show selecting the current panel.
Details

Declared type

{ step: FormStep; index: number }

Source

FormWizard.vue:217

packages/ui/src/components/form/FormWizard.vue

Accessibility ​

Form Structure ​

  • FormItem automatically generates unique IDs, ensuring correct associations between FormLabel, FormControl, FormDescription, and FormMessage.
  • FormLabel is associated with the corresponding input control via the for attribute.
  • FormControl provides id, aria-describedby, and aria-invalid attributes via a scoped slot, which should be bound to the inner input control to ensure accessibility.

Validation Errors ​

  • On validation failure, FormLabel automatically adds the text-brutal-destructive style.
  • FormMessage uses role="alert" to ensure screen readers announce error messages promptly.
  • The aria-invalid attribute on the input control is automatically set to true.

FormWizard Navigation ​

  • The step indicator uses role="tablist" and role="tab" semantics.
  • Supports keyboard navigation (Tab, Enter, Space).
  • When linear mode is enabled, blocked steps are identified with aria-disabled.

FAQ ​

Q: Form validation is not triggered on submit. What's wrong?

A: Make sure you have correctly installed and configured the vee-validate and @vee-validate/zod dependencies, and that validationSchema is passed to the Form component. Additionally, each field that needs validation must be wrapped in a FormField, and the FormField's name attribute must match the field name in the schema.

Q: How do I skip validation for certain steps in FormWizard?

A: Set optional: true in the steps configuration to mark a step as optional. Optional steps do not trigger validation when advancing. For finer control, you can customize the validation logic in the step's validator function and return { valid: true, errors: {} } to skip validation conditionally.

Q: Why isn't the FormConditional condition function working?

A: The when function of FormConditional receives the current form's complete value object as its argument. Make sure the field names are spelled correctly and the form values have been properly initialized via v-model or initialValues. If the condition depends on a field that hasn't been rendered yet or whose value is undefined, the condition evaluation may return unexpected results.

Brute force builds.