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 = ref({
  username: '',
  email: '',
  password: '',
})

const formOptions = {
  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: ['submit', 'cancel'],
}

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

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

const onValidate = (data, errors) => {
  console.log('Form validation result:', data, errors)
}
</script>

Example with Complex Validation and Custom Actions

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

<script setup lang="ts">
import { ref } from 'vue'

const isSubmitting = ref(false)
const formData = ref({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
})

const formOptions = {
  fields: [
    {
      name: 'username',
      type: 'text',
      label: 'Username',
      required: true,
      rules: [
        { type: 'min-length', params: 3, message: 'Username must be at least 3 characters long' },
      ],
    },
    {
      name: 'email',
      type: 'email',
      label: 'Email',
      required: true,
      rules: [{ type: 'email', params: true, message: 'Email is not valid' }],
    },
    {
      name: 'password',
      type: 'password',
      label: 'Password',
      required: true,
      rules: [
        {
          type: 'password',
          params: true,
          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', params: 'password', message: 'Passwords must match' }],
    },
  ],
  actions: [
    { name: 'submit', format: { text: 'Create Account', color: 'success', icon: 'mdi-account-plus' } },
    { name: 'validate', format: { color: 'info', variant: 'outlined' } },
    { name: 'cancel', format: { color: 'error', variant: 'text' } },
  ],
  actionAlign: 'center',
  cols: 2,
  focusFirst: true,
}

async function onSubmit(newData) {
  isSubmitting.value = true
  // Simulate API call
  await new Promise(resolve => setTimeout(resolve, 2000))
  isSubmitting.value = false
  console.log('Form submitted with data:', newData)
}
</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.
loadingBooleanNofalseShows a loading overlay with progress indicator when true.
tFunctionNo(text) => textTranslation function for internationalizing form content.

BsbFormOptions

PropertyTypeRequiredDefaultDescription
fieldsArray<BsbFormField>Yes[]Defines the fields to be rendered in the form.
colsnumberNo1Number of columns the form should use for layout (e.g., 3 columns).
actionsArray<BsbAction>No[]Defines the action buttons that are rendered below the form fields.
actionFormatBsbFormatNo{}Default format for action buttons.
actionAlign'left'/'right'/'center'No'left'Alignment of action buttons.
actionSubmitstringNo'submit'Name of the submit action.
actionResetstringNo'reset'Name of the reset action.
actionValidatestringNo'validate'Name of the validate action.
actionCancelstringNo'cancel'Name of the cancel action.
autocomplete'on'/'off'NoundefinedForm autocomplete behavior.
disabledbooleanNofalseDisables all form fields.
readonlybooleanNofalseMakes all form fields read-only.
fastFailbooleanNofalseIf true, stops validation on first error.
errorsArray<BsbFormFieldError>No[]Array of form-level errors to show on specific fields.
focusFirstbooleanNofalseAutomatically focus the first field when form loads.

BsbFormField

PropertyTypeRequiredDefaultDescription
typestringYes-The type of form input to render (text, email, password, etc).
namestringYes-The unique name identifier for the field.
valueanyNoundefinedDefault value for the field.
labelstringNo''Label for the form field. Supports translation.
placeholderstringNo''Placeholder text for the field. Supports translation.
autocomplete'on'/'off'NoundefinedBrowser autocomplete behavior for the field.
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 for the field.
suffixstringNo''Suffix text for the field.
variantstringNoundefinedThe visual variant of the input field.
densitystringNoundefinedDensity 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<BsbRule>No[]Array of validation rules applied to the field.
counterboolean/numberNoundefinedCharacter counter for text fields.

For specific field types, additional properties are available:

  • Textarea fields:

    • rows: Number of visible text rows (default: 5)
    • noResize: Prevent user from resizing (default: false)
    • autoGrow: Automatically adjust height to content (default: false)
  • Rating fields:

    • length: Number of rating items (default: 5)
    • size: Size of rating items (default: 24)
    • itemLabels: Labels for rating items
  • Selection fields (select, combobox, autocomplete):

    • items: Array of items or option values
    • chips: Display selected items as chips (default: false)
    • multiple: Allow multiple selections (default: false)

BsbAction

Actions can be either strings (using default settings) or objects for customization:

typescript
type BsbAction = string | {
  key?: string
  name: string
  format?: BsbFormat | BsbFormat[]
}

BsbFormat

typescript
type BsbFormat = {
  rules?: BsbRule | BsbRule[]
  text?: string
  icon?: string
  color?: string
  variant?: 'flat' | 'outlined' | 'plain' | 'text' | 'elevated' | 'tonal'
  density?: 'compact' | 'default' | 'comfortable'
  size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large'
  rounded?: boolean
  class?: string
  to?: string
  href?: string
  target?: string
}

BsbRule

typescript
type BsbRule = {
  type: string
  params: unknown
  message?: string
}

Supported rule types include: required, min-length, max-length, equals, equals-not, starts-with, ends-with, contains, greater-than, less-than, in-range, includes, set, password, email, url, ip, regexp, same-as, is-json, and custom.

Events

EventPayloadDescription
submitformData: BsbFormDataEmitted when the form is successfully validated and submitted.
cancelnoneEmitted when the form's cancel action is triggered.
resetnoneEmitted when the form's reset action is triggered.
validateformData, errors?Emitted after form validation, with data and optional error information.
actionactionName, formDataEmitted when a custom action is triggered.

Form Behavior

Validation

  • The form validates on invalid input by default (validate-on="invalid-input")
  • Validation happens when:
    • A field value changes
    • The form is submitted
    • The validate action is triggered

Data Synchronization

  • The form watches for changes in the data prop and updates field values accordingly
  • Initial field values can be set via the field's value property or through the data prop

Focus Management

  • When focusFirst is true, the first enabled and visible field is automatically focused
  • Enter key on the last field triggers form submission if a submit action is defined
  • Password fields include a visibility toggle button (eye icon)

Default Actions

The form supports several pre-configured action types:

  1. submit - Triggers form validation and submission
  2. cancel - Emits the cancel event
  3. reset - Resets form fields to initial values
  4. validate - Performs form validation

Custom actions can be defined and will emit the action event with the action name and current form data.

Supported Field Types

The component supports the following field types:

  • text - Standard text input
  • email - Email input with validation
  • password - Password input with visibility toggle
  • textarea - Multi-line text input
  • number - Numeric input
  • switch - Toggle switch
  • rating - Star rating input
  • checkbox - Checkbox input
  • select - Dropdown selection
  • combobox - Searchable dropdown
  • autocomplete - Text input with suggestions
  • file - File upload
  • date - Date picker
  • time - Time picker
  • datetime - Date and time picker

Source

Component 'components/VBsbForm.vue' (see below for file content)
vue
<template>
  <v-defaults-provider :defaults>
    <v-container>
      <v-form
        ref="form"
        validate-on="invalid-input"
        :autocomplete="options.autocomplete"
        :disabled="options.disabled"
        :readonly="options.readonly"
        :fast-fail="options.fastFail"
        @submit.prevent
      >
        <v-row>
          <v-col
            v-for="field in fields"
            :key="field.name"
            :cols="12 / (mobile ? 1 : options.cols || 1)"
          >
            <component
              :is="field.component"
              :id="field.name"
              v-model="values[field.name]"
              v-bind="field.props"
              :rules="field.rules()"
              :error-messages="field.errors()"
              @keyup.enter="handleFieldEnter(field.name)"
            />
          </v-col>
        </v-row>
        <v-row v-if="actions.length > 0">
          <v-col :class="bsbTextAlign(options.actionAlign)">
            <v-btn
              v-for="action in actions"
              :key="action.name"
              v-bind="action.props"
              @click="handleAction(action.name)"
            />
          </v-col>
        </v-row>
      </v-form>
      <v-overlay :model-value="loading" persistent contained class="align-center justify-center">
        <v-progress-circular indeterminate />
      </v-overlay>
    </v-container>
  </v-defaults-provider>
</template>

<script setup lang="ts">
import {
  VTextField,
  VSelect,
  VCombobox,
  VAutocomplete,
  VFileInput,
  VSwitch,
  VCheckbox,
  VRating,
  VTextarea,
} from 'vuetify/components'

import {
  type BsbFormOptions,
  type BsbFormData,
  bsbTextAlign,
  bsbActionFormat,
  bsbRuleValidate,
} from './index'

const { defaults } = useDefaults({
  name: 'VBsbForm',
  defaults: {
    VContainer: {
      class: 'position-relative',
    },
    VOverlay: {
      class: 'rounded',
    },
    VForm: {
      VBtn: {
        class: 'ma-1',
      },
    },
  },
})

const {
  options,
  data,
  t = (text?: string) => text || '',
  loading = false,
} = defineProps<{
  options: BsbFormOptions
  data?: BsbFormData
  t?: (text?: string) => string
  loading?: boolean
}>()

const emits = defineEmits<{
  (event: 'action', actionName: string, formData: BsbFormData): void
  (event: 'cancel'): void
  (event: 'reset'): void
  (event: 'submit', formData: BsbFormData): void
  (event: 'validate', formData: BsbFormData, errors?: unknown): void
}>()

watch(
  () => data,
  (newData) => {
    if (newData && Object.keys(newData).length) {
      values.value = { ...values.value, ...newData }
    }
  },
  { deep: true },
)

const { mobile } = useDisplay()

const form = ref()
const showPwd = ref(false)

const fields = computed(() => {
  const componentMap = {
    select: VSelect,
    combobox: VCombobox,
    autocomplete: VAutocomplete,
    file: VFileInput,
    switch: VSwitch,
    checkbox: VCheckbox,
    rating: VRating,
    textarea: VTextarea,
    text: VTextField,
    number: VTextField,
    email: VTextField,
    date: VTextField,
    time: VTextField,
    datetime: VTextField,
  }

  return (options.fields.filter((field) => !field.hidden) || []).map((field) => {
    const baseProps: Record<string, unknown> = {
      name: field.name,
      type:
        field.type == 'datetime'
          ? 'datetime-local'
          : field.type === 'password'
            ? showPwd.value
              ? 'text'
              : 'password'
            : field.type,
      label: field.label ? t(field.label) : undefined,
      placeholder: field.placeholder ? t(field.placeholder) : undefined,
      autocomplete: field.autocomplete || options.autocomplete,
      hint: field.hint ? t(field.hint) : undefined,
      clearable: field.clearable,
      prependIcon: field.prependIcon,
      appendIcon: field.appendIcon,
      prependInnerIcon: field.prependInnerIcon,
      appendInnerIcon:
        field.type === 'password'
          ? showPwd.value
            ? '$mdiEyeOff'
            : '$mdiEye'
          : field.prependInnerIcon,
      'onClick:appendInner':
        field.type === 'password' ? () => (showPwd.value = !showPwd.value) : undefined,
      required: field.required,
      readonly: field.readonly,
      disabled: field.disabled,
      variant: field.variant,
      density: field.density,
      color: field.color,
    }
    if (field.counter !== undefined) {
      baseProps.counter = ['textarea', 'text', 'email', 'password'].includes(field.type)
        ? field.counter
        : undefined
    }
    if (
      !['switch', 'rating', 'file', 'checkbox', 'select', 'combobox', 'autocomplete'].includes(
        field.type,
      )
    ) {
      baseProps.prefix = field.prefix
      baseProps.suffix = field.suffix
    }
    if (['switch', 'checkbox'].includes(field.type) && !field.color) {
      baseProps.color = 'primary'
    }
    if (field.type === 'rating' && 'form' in options && (options.disabled || options.readonly)) {
      baseProps.disabled = true
      baseProps.readonly = true
      baseProps.color = 'grey'
    }

    let specificProps: Record<string, unknown> = {}
    if (field.type === 'textarea') {
      const textareaField = field as BsbFormTextareaField
      specificProps = {
        rows: textareaField.rows || 5,
        noResize: textareaField.noResize,
        autoGrow: textareaField.autoGrow,
      }
    } else if (field.type === 'rating') {
      const ratingField = field as BsbFormRatingField
      specificProps = {
        length: ratingField.length || 5,
        size: ratingField.size || 24,
        itemLabels: ratingField.itemLabels || ([field.label] as string[]),
      }
    } else if (['select', 'combobox', 'autocomplete'].includes(field.type)) {
      const selectionField = field as BsbFormSelectionField
      specificProps = {
        items: selectionField.items || [],
        chips: selectionField.chips || false,
        multiple: selectionField.multiple || false,
        itemTitle: 'title',
        itemValue: 'value',
      }
    }

    return {
      component: componentMap[field.type as keyof typeof componentMap] || VTextField,
      name: field.name,
      props: { ...baseProps, ...specificProps },
      errors: (): string[] => {
        if (!options.errors) return []
        return options.errors
          .filter((error: BsbFormFieldError) => error.name === field.name)
          .map((error: BsbFormFieldError) => t(error.message || ''))
      },
      rules: () =>
        (field.rules || []).map(
          (rule) => (value: unknown) =>
            bsbRuleValidate(value, rule.type, rule.params, t(rule.message)),
        ),
    }
  })
})

const actions = computed(() => {
  return (options.actions || []).map((action) => {
    const props = bsbActionFormat(undefined, action, options.actionFormat)
    props.text = props.text ? t(String(props.text)) : undefined
    return {
      name: props.name as string,
      props,
    }
  })
})

const values = ref<BsbFormData>({})

const handleAction = async (actionName: string) => {
  const action = actions.value.find((action) => action.name === actionName)
  if (!action) return

  if (action.name === options.actionCancel) {
    await emits('cancel')
    return
  }

  if (action.name === options.actionReset) {
    resetValues()
    await emits('reset')
    return
  }

  if (action.name === options.actionSubmit) {
    const { valid, errors } = await form.value.validate()
    if (!valid) {
      formFocus(errors[0]?.id)
      return
    }
    await emits('submit', values.value)
    return
  }

  if (action.name === options.actionValidate) {
    const { valid, errors } = await form.value.validate()
    if (!valid) {
      formFocus(errors[0]?.id)
      await emits('validate', values.value, errors)
      return
    }
    await emits('validate', values.value)
    return
  }

  await emits('action', action.name, values.value)
}

async function handleFieldEnter(fieldName: string) {
  if (!options.actions) return
  const isLastField = fields.value[fields.value.length - 1].name === fieldName
  const hasSubmitAction = actions.value.find((action) => action.name === options.actionSubmit)
  if (isLastField && hasSubmitAction) await handleAction(hasSubmitAction.name)
}

function formFocus(elementId?: string) {
  if (!elementId) return
  const inputElement = document.getElementById(elementId) as HTMLInputElement | null
  if (inputElement) inputElement.focus()
}

function resetValues() {
  const fieldDefaults = Object.fromEntries(
    options.fields
      .filter((field) => field.value !== undefined)
      .map((field) => [field.name, field.value]),
  )
  values.value = { ...fieldDefaults, ...data }
}

onMounted(() => {
  resetValues()

  if (options.focusFirst && fields.value.length > 0) {
    formFocus(fields.value[0].name)
  }
})
</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'
import type { BsbFormOptions } from '../index' // Import the required types

describe('VBsbForm', () => {
  let wrapper: VueWrapper
  const options: BsbFormOptions = {
    fields: [
      {
        name: 'username',
        type: 'text' as const,
        label: 'Username',
        value: '',

        errors: [],
      },
      {
        name: 'checkbox',
        type: 'checkbox' as const,
        label: 'Checkbox',
        value: false,

        errors: [],
      },
      {
        name: 'password',
        type: 'password' as const,
        label: 'Password',
        value: '',

        errors: [],
      },
    ],
    actions: ['submit', 'cancel'],
    actionSubmit: 'submit',
    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 checkboxField = wrapper.findComponent({ name: 'VCheckbox' })
    expect(checkboxField.props('label')).toBe('Checkbox')
  })

  it('renders action buttons correctly', () => {
    const buttons = wrapper.findAllComponents({ name: 'VBtn' })
    expect(buttons).toHaveLength(2)
    expect(buttons[0].html()).toContain('submit')
    expect(buttons[1].html()).toContain('cancel')
  })
})