Skip to content

VBsbForm Component Documentation

Overview

The VBsbForm component is a dynamic and flexible form generator built using Vuetify components. It is designed to handle a wide variety of form inputs and actions, making it suitable for complex forms that can be customized through props like fields, options, and actions. This component dynamically generates form elements based on the configuration passed via props and supports validation, submit, and cancel actions.

Features

Automatic Behaviors

  • First Field Auto-Focus: Automatically focuses the first enabled and visible field when the form loads
  • Enter Key Submission: Pressing Enter on the last field triggers form submission
  • Password Visibility Toggle: Password fields include a show/hide toggle button
  • Data Synchronization: Automatically syncs form field values with the provided data prop
  • Translation Support: Built-in support for internationalization of labels, placeholders, and messages
  • Default Actions: Pre-configured submit, cancel, and validate actions with customizable appearance

Form Validation

  • Validates on invalid input by default
  • Supports extensive validation rules
  • Provides field-level and form-level error handling
  • Real-time validation feedback

Usage Examples

Basic Example

vue
<template>
  <VBsbForm
    :data="formData"
    :options="formOptions"
    @submit="onSubmit"
    @cancel="onCancel"
    @validate="onValidate"
  />
</template>

<script setup lang="ts">
const formData = {
  username: '',
  email: '',
  password: '',
}

const formOptions = {
  cols: 2,
  fields: [
    { name: 'username', type: 'text', label: 'Username', required: true },
    { name: 'email', type: 'email', label: 'Email', required: true },
    { name: 'password', type: 'password', label: 'Password', required: true },
  ],
  actions: [
    { type: 'submit', title: 'Submit', color: 'success' },
    { type: 'cancel', title: 'Cancel', color: 'error', variant: 'outlined' },
  ],
}

const onSubmit = (data: any) => {
  console.log('Form submitted with data:', data)
}

const onCancel = () => {
  console.log('Form cancelled')
}

const onValidate = ({ valid, errors }: { valid: boolean; errors: any[] }) => {
  console.log('Form validation result:', valid, errors)
}
</script>

Example with Complex Validation and Custom Actions

vue
<template>
  <VBsbForm :data="formData" :options="formOptions" @submit="onSubmit" />
</template>

<script setup lang="ts">
const formData = {
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
}

const formOptions = {
  fields: [
    {
      name: 'username',
      type: 'text',
      label: 'Username',
      required: true,
      rules: [
        { type: 'min-length', value: 3, message: 'Username must be at least 3 characters long' },
      ],
    },
    {
      name: 'email',
      type: 'email',
      label: 'Email',
      required: true,
      rules: [{ type: 'email', message: 'Email is not valid' }],
    },
    {
      name: 'password',
      type: 'password',
      label: 'Password',
      required: true,
      rules: [
        {
          type: 'password',
          message: 'Password must be at least 8 characters with letters and numbers',
        },
      ],
    },
    {
      name: 'confirmPassword',
      type: 'password',
      label: 'Confirm Password',
      required: true,
      rules: [{ type: 'same-as', value: 'password', message: 'Passwords must match' }],
    },
  ],
  actions: [
    { type: 'submit', title: 'Create Account', color: 'success', icon: '$mdiAccountPlus' },
    { type: 'validate', title: 'Check Form', color: 'info', variant: 'outlined' },
    { type: 'cancel', title: 'Reset', color: 'error', variant: 'text' },
  ],
  actionsAlign: 'right',
  actionsClass: 'mt-4',
}
</script>

API

Props

PropTypeRequiredDefaultDescription
dataObjectNo{}Initial form data object. This object is reactive and updated as the form inputs change.
optionsBsbFormOptionsYes{}Configuration object that defines the fields, actions, and settings for the form.

BsbFormOptions

PropertyTypeRequiredDefaultDescription
fieldsArray<BsbFormField>Yes[]Defines the fields to be rendered in the form.
colsnumberNo1Number of columns the form should span.
variantstringNooutlinedThe visual variant of form fields (e.g., outlined, filled, etc.).
density'default' | 'comfortable' | 'compact'No'default'Density of the form fields (spacing and padding).
actionsArray<BsbFormAction>No[]Defines the action buttons that are rendered below the form fields.
actionsAlign'left' | 'right'No'left'Alignment of action buttons.
actionsClassstringNo''CSS class to apply to the actions container.
errorsArray<BsbFormError>No[]Array of form-level errors. This is used to display error messages at the field level.

BsbFormField

PropertyTypeRequiredDefaultDescription
type'text' | 'number' | 'password' | 'email' | 'textarea' | 'switch' | 'rating' | 'checkbox' | 'select' | 'combobox' | 'autocomplete' | 'file' | 'date' | 'time' | 'datetime'YestextThe type of form input to render.
namestringYes''The unique name identifier for the field.
valueanyNo''The current value of the field.
labelstringNo''Label for the form field. Supports translation.
placeholderstringNo''Placeholder text for the field. Supports translation.
requiredbooleanNofalseWhether the field is required.
readonlybooleanNofalseWhether the field is read-only.
hiddenbooleanNofalseWhether the field is hidden.
disabledbooleanNofalseWhether the field is disabled.
clearablebooleanNofalseWhether the field has a clear button.
prefixstringNo''Prefix text or icon for the field.
suffixstringNo''Suffix text or icon for the field.
variant'outlined' | 'filled' | 'underlined' | 'solo' | 'solo-inverted' | 'solo-filled' | 'plain'No'outlined'The visual variant of the input field.
density'default' | 'comfortable' | 'compact'No'default'Density of the field.
colorstringNoundefinedColor of the field when active.
hintstringNo''Hint text to display below the field. Supports translation.
prependIconstringNoundefinedIcon to display before the field.
appendIconstringNoundefinedIcon to display after the field.
prependInnerIconstringNoundefinedIcon to display inside the field before the content.
appendInnerIconstringNoundefinedIcon to display inside the field after the content. For password fields, this shows the visibility toggle.
rulesArray<BsbFormValidationRule>No[]Array of validation rules applied to the field.
errorsstring[]No[]Array of error messages to display for this field.
rowsnumberNo5Number of rows for textarea fields.
counterboolean | number | stringNoundefinedCharacter counter for textarea fields.
noResizebooleanNofalsePrevents textarea resizing.
autoGrowbooleanNofalseEnables textarea auto-growing.
lengthnumberNo5Number of stars for rating fields.
sizenumberNo24Size of rating stars in pixels.
itemsstring[]No[]Array of items for select, combobox, and autocomplete fields.
chipsbooleanNofalseDisplay selected items as chips.
multiplebooleanNofalseAllow multiple selections.

BsbFormAction

PropertyTypeRequiredDefaultDescription
type'submit' | 'cancel' | 'validate'YessubmitThe type of action button. Determines its functionality (e.g., submitting the form).
titlestringNo''The text label of the action button. Supports translation.
iconstringNo''Icon name to show on the button.
colorstringNo'primary'The color of the action button.
variant'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'No'text'The visual style of the action button.
density'default' | 'comfortable' | 'compact'No'default'Density of the action button (spacing and padding).

BsbFormValidationRule

PropertyTypeRequiredDefaultDescription
type'required' | 'min-length' | 'max-length' | 'equals' | 'equals-not' | 'starts-with' | 'ends-with' | 'greater-than' | 'less-than' | 'in-range' | 'set' | 'password' | 'email' | 'url' | 'ip' | 'regexp' | 'same-as' | 'is-json' | 'custom'YesrequiredThe type of validation rule applied to the form field.
valueanyNonullThe value associated with the validation rule (e.g., minimum length, comparison value).
messagestringYes''The validation error message to show if the rule is violated. Supports translation.

Events

EventPayloadDescription
submitdata: Record<string, any>Emitted when the form is successfully validated and submitted.
cancelvoidEmitted when the form's cancel action is triggered.
validate{ valid: boolean, errors: any[] }Emitted after form validation occurs, providing whether the form is valid and any errors.
action{ action: string, data: Record<string, any> }Emitted when a custom action is triggered.

Form Behavior

Validation

  • The form validates on invalid input by default (validate-on="invalid-input")
  • Validation is performed when:
    • A field value changes
    • The form is submitted
    • The validate action is triggered
  • Field-level validation uses the rules defined in the field configuration
  • Form-level validation aggregates all field validations

Data Synchronization

  • The form watches for changes in the data prop and updates field values accordingly
  • When form values change, the parent component is updated through events
  • The refresh() method is called internally to sync data and field values

Focus Management

  • On mount, the first enabled and visible field is automatically focused
  • Enter key on the last field triggers form submission
  • Password fields include a visibility toggle button

Default Actions

The form comes with three pre-configured actions:

  1. Submit (type: 'submit')
    • Icon: check mark
    • Color: success
    • Triggers form validation and submission
  2. Cancel (type: 'cancel')
    • Icon: close
    • Color: error
    • Variant: outlined
    • Resets form to initial values
  3. Validate (type: 'validate')
    • Icon: check mark
    • Color: info
    • Variant: outlined
    • Triggers form validation

These default actions can be:

  • Used directly by specifying action types as strings
  • Customized by providing full action objects
  • Mixed with custom actions
  • Reordered or subset selected

Internationalization

The component supports translation through the t() function for:

  • Field labels
  • Placeholders
  • Hints
  • Validation messages
  • Action button texts
  • Error messages

Source

Component 'components/VBsbForm.vue' (see below for file content)
vue
<template>
  <v-form
    :v-model="valid"
    :data="data"
    :options="options"
    ref="form"
    @submit.prevent
    validate-on="invalid-input"
  >
    <v-row>
      <v-col v-for="field in options.fields" :key="field.name" :cols="12 / (options.cols || 1)">
        <v-defaults-provider
          :defaults="{ VTextField: { variant: options.variant, density: options.density } }"
        >
          <v-text-field
            v-if="
              field.type == 'text' ||
              field.type == 'number' ||
              field.type == 'password' ||
              field.type == 'email' ||
              field.type == 'date' ||
              field.type == 'time' ||
              field.type == 'datetime'
            "
            :id="field.name"
            :type="
              field.type == 'password'
                ? showPwd
                  ? 'text'
                  : 'password'
                : field.type == 'datetime'
                  ? 'datetime-local'
                  : field.type
            "
            v-model="field.value"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :placeholder="field.placeholder && t(field.placeholder)"
            :clearable="field.clearable"
            :prefix="field.prefix"
            :suffix="field.suffix"
            :required="field.required"
            :readonly="field.readonly"
            :hidden="field.hidden"
            :disabled="field.disabled"
            :variant="field.variant"
            :density="options.density"
            :color="field.color"
            :hint="field.hint && t(field.hint)"
            :prepend-icon="field.prependIcon"
            :prepend-inner-icon="field.prependInnerIcon"
            :append-icon="field.appendIcon"
            :append-inner-icon="
              field.appendInnerIcon
                ? field.appendInnerIcon
                : field.type == 'password'
                  ? showPwd
                    ? '$mdiEye'
                    : '$mdiEyeOff'
                  : ''
            "
            @click:append-inner="showPwd = field.type == 'password' ? !showPwd : showPwd"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
          <v-textarea
            v-if="field.type == 'textarea'"
            :id="field.name"
            v-model="field.value"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :placeholder="field.placeholder && t(field.placeholder)"
            :clearable="field.clearable"
            :prefix="field.prefix"
            :suffix="field.suffix"
            :required="field.required"
            :readonly="field.readonly"
            :hidden="field.hidden"
            :disabled="field.disabled"
            :variant="field.variant"
            :density="options.density"
            :color="field.color"
            :hint="field.hint && t(field.hint)"
            :prepend-icon="field.prependIcon"
            :prepend-inner-icon="field.prependInnerIcon"
            :append-icon="field.appendIcon"
            :append-inner-icon="field.appendInnerIcon"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
            :rows="field.rows || 5"
            :counter="field.counter"
            :no-resize="field.noResize"
            :auto-grow="field.autoGrow"
          />
          <v-switch
            v-if="field.type == 'switch'"
            :id="field.name"
            v-model="field.value"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :color="field.color || 'primary'"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
          <v-label class="d-block" v-if="field.type == 'rating'" :for="field.name">{{
            t(field.label || '')
          }}</v-label>
          <v-rating
            v-if="field.type == 'rating'"
            :color="field.color || 'primary'"
            :id="field.name"
            v-model="field.value as number"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
            :length="field.length || 5"
            :size="field.size || 24"
          />
          <v-checkbox
            v-if="field.type == 'checkbox'"
            :id="field.name"
            v-model="field.value"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :color="field.color || 'primary'"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
          <v-select
            v-if="field.type == 'select'"
            :id="field.name"
            v-model="field.value as string"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :variant="field.variant"
            :density="options.density"
            :color="field.color"
            :hint="field.hint && t(field.hint)"
            :items="field.items || []"
            :multiple="field.multiple || false"
            :chips="field.chips || false"
            :clearable="field.clearable || true"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
          <v-combobox
            v-if="field.type == 'combobox'"
            :id="field.name"
            v-model="field.value as string"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :variant="field.variant"
            :density="options.density"
            :color="field.color"
            :hint="field.hint && t(field.hint)"
            :items="field.items || []"
            :multiple="field.multiple || false"
            :chips="field.chips || false"
            :clearable="field.clearable || true"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
          <v-autocomplete
            v-if="field.type == 'autocomplete'"
            :id="field.name"
            v-model="field.value as string"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :variant="field.variant"
            :density="options.density"
            :color="field.color"
            :hint="field.hint && t(field.hint)"
            :items="field.items || []"
            :multiple="field.multiple || false"
            :chips="field.chips || false"
            :clearable="field.clearable || true"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
          <v-file-input
            v-if="field.type == 'file'"
            :id="field.name"
            v-model="field.value as File | File[] | null"
            v-show="!field.hidden"
            :label="field.label && t(field.label)"
            :required="field.required"
            :readonly="field.readonly"
            :disabled="field.disabled"
            :variant="field.variant"
            :density="options.density"
            :color="field.color"
            :hint="field.hint && t(field.hint)"
            :multiple="field.multiple || false"
            :chips="field.chips || false"
            :clearable="field.clearable || true"
            :prepend-icon="field.prependIcon"
            :prepend-inner-icon="field.prependInnerIcon"
            :rules="rules(field.rules)"
            :error-messages="errorMessages(field.name)"
          />
        </v-defaults-provider>
      </v-col>
    </v-row>
    <v-row v-if="options.actions">
      <v-col :class="options.actionsAlign == 'right' ? 'text-right' : ''">
        <v-btn
          v-for="action in options.actions"
          :key="action.type"
          @click="onAction(action.type as string)"
          :color="action.color"
          :prepend-icon="action.icon"
          :variant="action.variant"
          :density="action.density"
          :class="options.actionsClass"
          >{{ t(action.title || '') }}</v-btn
        >
      </v-col>
    </v-row>
  </v-form>
</template>

<script setup lang="ts">
export type BsbFormValidationRule = {
  type:
    | 'required'
    | 'min-length'
    | 'max-length'
    | 'equals'
    | 'equals-not'
    | 'starts-with'
    | 'ends-with'
    | 'greater-than'
    | 'less-than'
    | 'in-range'
    | 'set'
    | 'password'
    | 'email'
    | 'url'
    | 'ip'
    | 'regexp'
    | 'same-as'
    | 'is-json'
    | 'custom'
  value?: unknown
  message: string
}

export type BsbFormField = {
  type:
    | 'text'
    | 'number'
    | 'password'
    | 'email'
    | 'textarea'
    | 'switch'
    | 'rating'
    | 'checkbox'
    | 'select'
    | 'combobox'
    | 'autocomplete'
    | 'file'
    | 'date'
    | 'time'
    | 'datetime'
  name: string
  value?: unknown
  label?: string
  required?: boolean
  readonly?: boolean
  hidden?: boolean
  disabled?: boolean
  placeholder?: string
  clearable?: boolean
  prefix?: string
  suffix?: string
  variant?:
    | 'underlined'
    | 'outlined'
    | 'filled'
    | 'solo'
    | 'solo-inverted'
    | 'solo-filled'
    | 'plain'
  density?: 'default' | 'comfortable' | 'compact'
  color?: string
  hint?: string
  prependIcon?: string
  appendIcon?: string
  prependInnerIcon?: string
  appendInnerIcon?: string
  rules?: Array<BsbFormValidationRule>
  errors?: string[]
  //textarea
  rows?: number
  counter?: string | number | true | undefined
  noResize?: boolean
  autoGrow?: boolean
  //rating
  length?: number
  size?: number
  //select, combobox, autocomplete
  items?: string[]
  chips?: boolean
  multiple?: boolean
}

export type BsbFormAction = {
  type: 'submit' | 'cancel' | 'validate'
  title?: string
  icon?: string
  color?: string
  variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
  density?: 'default' | 'comfortable' | 'compact'
}

export type BsbFormError = {
  name?: string
  message?: string
  errors?: string[]
}

export type BsbFormOptions = {
  fields: Array<BsbFormField>
  cols?: number
  variant?:
    | 'underlined'
    | 'outlined'
    | 'filled'
    | 'solo'
    | 'solo-inverted'
    | 'solo-filled'
    | 'plain'
  density?: 'default' | 'comfortable' | 'compact'
  actions?: Array<BsbFormAction> | []
  actionsAlign?: 'left' | 'right'
  actionsClass?: string
  errors: Array<BsbFormError>
  api?: string
}

const props = defineProps({
  data: {
    type: Object as PropType<Record<string, unknown>>,
    default: () => ({ cols: 1 }),
    required: false,
  },
  options: {
    type: Object as PropType<BsbFormOptions>,
    default: () => ({ fields: [] }),
    required: true,
  },
})

const emits = defineEmits(['submit', 'cancel', 'validate', 'action'])

const http = props.options.api ? useHttp({ baseURL: props.options.api }) : undefined

const { t, locale } = useI18n()
watch(
  () => locale.value,
  async () => {
    await validate()
  },
)

const data = ref(props.data)
watch(
  () => props.data,
  async (newData: Record<string, unknown>) => {
    data.value = newData
    await refresh()
  },
)

const options = ref(props.options)
watch(
  () => props.options,
  (newOptions: BsbFormOptions) => {
    options.value = newOptions
  },
)

import { VForm } from 'vuetify/components'
const form = ref<InstanceType<typeof VForm> | null>(null)
const valid = ref(false)

const showPwd = ref(false)

const defaultActions: Array<BsbFormAction> = [
  {
    type: 'submit',
    title: 'Submit',
    icon: '$mdiCheck',
    color: 'success',
  },
  {
    type: 'cancel',
    title: 'Cancel',
    icon: '$mdiClose',
    color: 'error',
    variant: 'outlined',
  },
  {
    type: 'validate',
    title: 'Validate',
    icon: '$mdiCheck',
    color: 'info',
    variant: 'outlined',
  },
]

function rules(r?: BsbFormValidationRule[]): (() => boolean | string)[] {
  function isValidJson(str: string) {
    try {
      const result = JSON.parse(str)
      const type = Object.prototype.toString.call(result)
      return type === '[object Object]' || type === '[object Array]'
    } catch {
      return false
    }
  }
  const result: (() => boolean | string)[] = []

  r?.map((rule) => {
    let f: (v: string) => boolean | string
    switch (rule.type) {
      case 'required':
        f = (v: string) => !!v || t(rule.message)
        break
      case 'min-length':
        f = (v: string) =>
          v.length >= Number(rule.value) ||
          t(rule.message).replace('{{ value }}', String(rule.value))
        break
      case 'max-length':
        f = (v: string) =>
          v.length <= Number(rule.value) ||
          t(rule.message).replace('{{ value }}', String(rule.value))
        break
      case 'equals':
        f = (v: string) => v === rule.value || t(rule.message)
        break
      case 'equals-not':
        f = (v: string) => v !== rule.value || t(rule.message)
        break
      case 'starts-with':
        f = (v: string) => v.startsWith(String(rule.value)) || t(rule.message)
        break
      case 'ends-with':
        f = (v: string) => v.endsWith(String(rule.value)) || t(rule.message)
        break
      case 'greater-than':
        f = (v: string) => Number(v) > Number(rule.value) || t(rule.message)
        break
      case 'less-than':
        f = (v: string) => Number(v) < Number(rule.value) || t(rule.message)
        break
      case 'in-range':
        f = (v: string) => {
          const [min, max] = rule.value as [number, number]
          return (Number(v) >= min && Number(v) <= max) || t(rule.message)
        }
        break
      case 'set':
        f = (v: string) => (Array.isArray(rule.value) && rule.value.includes(v)) || t(rule.message)
        break
      case 'password':
        f = (v: string) => /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(v) || t(rule.message)
        break
      case 'email':
        f = (v: string) => /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/.test(v) || t(rule.message)
        break
      case 'url':
        f = (v: string) => /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/\S*)?$/.test(v) || t(rule.message)
        break
      case 'ip':
        f = (v: string) =>
          /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
            v,
          ) || t(rule.message)
        break
      case 'regexp':
        f = (v: string) => new RegExp(String(rule.value)).test(v) || t(rule.message)
        break
      case 'same-as':
        f = (v: string) =>
          v == options.value.fields.find((field) => field.name == String(rule.value))?.value ||
          t(rule.message)
        break
      case 'is-json':
        f = (v: string) => isValidJson(v) || t(rule.message)
        break
      case 'custom':
        f = (v: string) => {
          const validationResult = typeof rule.value === 'function' ? rule.value(v) : true
          return validationResult === true ? true : t(validationResult || rule.message)
        }
        break
    }

    result.push(f! as () => boolean | string)
  })

  return result
}

function errorMessages(name: string): string[] {
  if (!options.value.errors) return []
  return options.value.errors
    .filter((error) => error.name == name)
    .map((error) => t(error.message || '')) as string[]
}

const validate = async () => {
  if (!form.value) return false
  const result = await form.value.validate()
  valid.value = result.valid
  emits('validate', { valid: valid.value, errors: result.errors })
  return valid.value
}

const cancel = async () => {
  data.value = props.data
  options.value.errors = []
  refresh()
  emits('cancel')
}

import { type HttpResponse } from '@/composables/http'

const submit = async () => {
  if (!form.value) return false
  if (await validate()) {
    const newData = { ...data.value }
    options.value.fields.forEach((field) => {
      newData[field.name] = field.value
    })
    if (props.options.api) {
      const response = (await http?.post('', newData)) as HttpResponse<{ errors: [BsbFormError] }>
      if (response.data?.errors && response.data.errors.length > 0) {
        options.value.errors = response.data.errors
        valid.value = false
        return false
      } else {
        data.value = { ...newData }
        valid.value = true
        options.value.errors = []
        emits('submit', newData)
      }
    } else {
      options.value.errors = []
      emits('submit', newData)
    }
  }
}

const action = async (actionType: string) => {
  const newData = { ...data.value }
  options.value.fields.forEach((field) => {
    newData[field.name] = field.value
  })
  emits('action', { action: actionType, data: newData })
}

const onAction = async (actionType: string) => {
  switch (actionType) {
    case 'submit':
      await submit()
      break
    case 'cancel':
      await cancel()
      break
    case 'validate':
      await validate()
      break
    default:
      action(actionType)
      break
  }
}

const refresh = async () => {
  if (
    Array.isArray(options.value.actions) &&
    options.value.actions.every((item) => typeof item === 'string')
  ) {
    const actions = options.value.actions as string[]
    options.value.actions = defaultActions.filter((item: BsbFormAction) =>
      actions.includes(item.type),
    )
  }
  options.value.fields.forEach((field) => {
    if (field.name in data.value) {
      field.value = data.value[field.name]
    }
  })
}

const focusFirstField = () => {
  const firstField = options.value.fields.find((field) => !field.hidden && !field.disabled)
  if (firstField) {
    const inputElement = document.getElementById(firstField.name) as HTMLInputElement | null
    if (inputElement) {
      inputElement.focus()
    }
  }
}

let lastFieldInputElement: HTMLInputElement | null = null
let enterKeyListener: ((event: KeyboardEvent) => Promise<void>) | null = null

const addEnterKeyListenerToLastField = () => {
  const lastField = [...options.value.fields]
    .reverse()
    .find((field) => !field.hidden && !field.disabled)
  if (lastField) {
    lastFieldInputElement = document.getElementById(lastField.name) as HTMLInputElement | null
    if (lastFieldInputElement) {
      enterKeyListener = async (event: KeyboardEvent) => {
        if (event.key === 'Enter') {
          event.preventDefault()
          await submit()
        }
      }
      lastFieldInputElement.addEventListener('keydown', enterKeyListener)
    }
  }
}

onMounted(async () => {
  await refresh()
  focusFirstField()
  addEnterKeyListenerToLastField()
})

onBeforeUnmount(() => {
  if (lastFieldInputElement && enterKeyListener) {
    lastFieldInputElement.removeEventListener('keydown', enterKeyListener)
  }
})
</script>
Test 'components/__tests__/VBsbForm.test.ts' (see below for file content)
ts
// tests/VBsbForm.spec.ts
import { mount, VueWrapper } from '@vue/test-utils'
import { describe, expect, it, beforeEach } from 'vitest'
import VBsbForm from '../VBsbForm.vue'
import vuetify from '../../plugins/vuetify'

describe('VBsbForm', () => {
  let wrapper: VueWrapper

  const options = {
    fields: [
      {
        name: 'username',
        type: 'text' as const,
        label: 'Username',
        value: '',
        required: true,
        errors: [],
      },
      {
        name: 'checkbox',
        type: 'checkbox' as const,
        label: 'Checkbox',
        value: false,
        required: true,
        errors: [],
      },
      {
        name: 'password',
        type: 'password' as const,
        label: 'Password',
        value: '',
        required: true,
        errors: [],
      },
    ],
    actions: [
      { type: 'submit' as const, title: 'Submit', color: 'success' },
      { type: 'cancel' as const, title: 'Cancel', color: 'error', variant: 'outlined' as const },
    ],
    errors: [],
  }

  const data = {
    username: '',
    email: '',
    password: '',
  }

  beforeEach(() => {
    wrapper = mount(VBsbForm, {
      props: {
        data,
        options,
      },
      global: {
        plugins: [vuetify],
      },
    })
  })

  it('renders form fields correctly', () => {
    const textFields = wrapper.findAllComponents({ name: 'VTextField' })
    expect(textFields).toHaveLength(2)
    expect(textFields[0].props('label')).toBe('Username')
    expect(textFields[1].props('label')).toBe('Password')

    const passwordField = wrapper.findComponent({ name: 'VCheckbox' })
    expect(passwordField.props('label')).toBe('Checkbox')
  })

  it('renders action buttons correctly', () => {
    const buttons = wrapper.findAllComponents({ name: 'VBtn' })
    expect(buttons).toHaveLength(2) // Submit, Cancel
    expect(buttons[0].text()).toBe('Submit')
    expect(buttons[1].text()).toBe('Cancel')
  })
})