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
<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
<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
Prop | Type | Required | Default | Description |
---|---|---|---|---|
data | Object | No | {} | Initial form data object. This object is reactive and updated as the form inputs change. |
options | BsbFormOptions | Yes | {} | Configuration object that defines the fields, actions, and settings for the form. |
loading | Boolean | No | false | Shows a loading overlay with progress indicator when true. |
t | Function | No | (text) => text | Translation function for internationalizing form content. |
BsbFormOptions
Property | Type | Required | Default | Description |
---|---|---|---|---|
fields | Array<BsbFormField> | Yes | [] | Defines the fields to be rendered in the form. |
cols | number | No | 1 | Number of columns the form should use for layout (e.g., 3 columns). |
actions | Array<BsbAction> | No | [] | Defines the action buttons that are rendered below the form fields. |
actionFormat | BsbFormat | No | {} | Default format for action buttons. |
actionAlign | 'left'/'right'/'center' | No | 'left' | Alignment of action buttons. |
actionSubmit | string | No | 'submit' | Name of the submit action. |
actionReset | string | No | 'reset' | Name of the reset action. |
actionValidate | string | No | 'validate' | Name of the validate action. |
actionCancel | string | No | 'cancel' | Name of the cancel action. |
autocomplete | 'on'/'off' | No | undefined | Form autocomplete behavior. |
disabled | boolean | No | false | Disables all form fields. |
readonly | boolean | No | false | Makes all form fields read-only. |
fastFail | boolean | No | false | If true, stops validation on first error. |
errors | Array<BsbFormFieldError> | No | [] | Array of form-level errors to show on specific fields. |
focusFirst | boolean | No | false | Automatically focus the first field when form loads. |
BsbFormField
Property | Type | Required | Default | Description |
---|---|---|---|---|
type | string | Yes | - | The type of form input to render (text, email, password, etc). |
name | string | Yes | - | The unique name identifier for the field. |
value | any | No | undefined | Default value for the field. |
label | string | No | '' | Label for the form field. Supports translation. |
placeholder | string | No | '' | Placeholder text for the field. Supports translation. |
autocomplete | 'on'/'off' | No | undefined | Browser autocomplete behavior for the field. |
required | boolean | No | false | Whether the field is required. |
readonly | boolean | No | false | Whether the field is read-only. |
hidden | boolean | No | false | Whether the field is hidden. |
disabled | boolean | No | false | Whether the field is disabled. |
clearable | boolean | No | false | Whether the field has a clear button. |
prefix | string | No | '' | Prefix text for the field. |
suffix | string | No | '' | Suffix text for the field. |
variant | string | No | undefined | The visual variant of the input field. |
density | string | No | undefined | Density of the field. |
color | string | No | undefined | Color of the field when active. |
hint | string | No | '' | Hint text to display below the field. Supports translation. |
prependIcon | string | No | undefined | Icon to display before the field. |
appendIcon | string | No | undefined | Icon to display after the field. |
prependInnerIcon | string | No | undefined | Icon to display inside the field before the content. |
appendInnerIcon | string | No | undefined | Icon to display inside the field after the content. For password fields, this shows the visibility toggle. |
rules | Array<BsbRule> | No | [] | Array of validation rules applied to the field. |
counter | boolean/number | No | undefined | Character 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 valueschips
: 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:
type BsbAction = string | {
key?: string
name: string
format?: BsbFormat | BsbFormat[]
}
BsbFormat
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
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
Event | Payload | Description |
---|---|---|
submit | formData: BsbFormData | Emitted when the form is successfully validated and submitted. |
cancel | none | Emitted when the form's cancel action is triggered. |
reset | none | Emitted when the form's reset action is triggered. |
validate | formData, errors? | Emitted after form validation, with data and optional error information. |
action | actionName, formData | Emitted 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 thedata
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:
submit
- Triggers form validation and submissioncancel
- Emits the cancel eventreset
- Resets form fields to initial valuesvalidate
- 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 inputemail
- Email input with validationpassword
- Password input with visibility toggletextarea
- Multi-line text inputnumber
- Numeric inputswitch
- Toggle switchrating
- Star rating inputcheckbox
- Checkbox inputselect
- Dropdown selectioncombobox
- Searchable dropdownautocomplete
- Text input with suggestionsfile
- File uploaddate
- Date pickertime
- Time pickerdatetime
- Date and time picker
Source
Component 'components/VBsbForm.vue
' (see below for file content)
<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)
// 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')
})
})